Files
certctl/internal/connector/issuer/stepca/stepca.go
T
shankar0123 ec21c9bb29 feat(m28+m29+m30): ACME ARI, email digest, and Helm chart
M28: ACME Renewal Information (RFC 9702) — CA-directed renewal timing
with cert ID computation, directory endpoint discovery, graceful
degradation for non-ARI CAs. 19 tests.

M29: Email notifier wiring + scheduled certificate digest — SMTP
connector bridged to service layer via NotifierAdapter, DigestService
with HTML email template, 7th scheduler loop (24h), digest preview/send
API endpoints and GUI card. 21 tests.

M30: Production-ready Helm chart — server Deployment, PostgreSQL
StatefulSet, agent DaemonSet, ConfigMaps, Secrets, Ingress, security
contexts, health probes, example values for dev/prod/ACME scenarios.

Also: OpenAPI spec updates, MCP tool additions, CI helm-lint job,
documentation updates across 5 doc files and README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:18:35 -04:00

482 lines
15 KiB
Go

// Package stepca implements the issuer.Connector interface for Smallstep step-ca
// private certificate authority.
//
// step-ca is a popular open-source private CA that provides both ACME and native
// provisioner-based certificate issuance. This connector uses the native /sign API
// with JWK provisioner authentication, which is simpler than ACME for internal PKI:
// no challenge solving, no domain validation — just CSR + auth token → signed cert.
//
// For teams already using step-ca, this connector integrates certctl's lifecycle
// management (renewal policies, deployment, audit) with step-ca's certificate signing.
//
// Authentication: JWK provisioner with a shared provisioner password.
// The connector generates a short-lived token for each signing request using the
// provisioner key (loaded from disk or provided inline).
//
// step-ca API used:
//
// POST /sign — submit CSR with provisioner token, receive signed certificate
// POST /revoke — revoke a certificate by serial
// GET /health — check CA availability
package stepca
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the step-ca issuer connector configuration.
type Config struct {
// CAURL is the base URL of the step-ca server (e.g., "https://ca.internal:9000").
CAURL string `json:"ca_url"`
// RootCertPath is the path to the step-ca root certificate PEM (for TLS verification).
// If empty, the system trust store is used.
RootCertPath string `json:"root_cert_path,omitempty"`
// ProvisionerName is the name of the JWK provisioner to use for signing.
ProvisionerName string `json:"provisioner_name"`
// ProvisionerKeyPath is the path to the provisioner's encrypted private key (JWK JSON).
// This is the key file generated by `step ca provisioner add`.
ProvisionerKeyPath string `json:"provisioner_key_path,omitempty"`
// ProvisionerPassword is the password to decrypt the provisioner key.
// Can also be set via CERTCTL_STEPCA_PROVISIONER_PASSWORD env var.
ProvisionerPassword string `json:"provisioner_password,omitempty"`
// ValidityDays is the requested certificate validity (step-ca may enforce a maximum).
// Defaults to 90.
ValidityDays int `json:"validity_days,omitempty"`
}
// Connector implements the issuer.Connector interface for step-ca.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
}
// New creates a new step-ca connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
if config != nil && config.ValidityDays == 0 {
config.ValidityDays = 90
}
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// ValidateConfig checks that the step-ca configuration is valid and the CA 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 step-ca config: %w", err)
}
if cfg.CAURL == "" {
return fmt.Errorf("step-ca ca_url is required")
}
if cfg.ProvisionerName == "" {
return fmt.Errorf("step-ca provisioner_name is required")
}
if cfg.ValidityDays == 0 {
cfg.ValidityDays = 90
}
// Check CA health
healthURL := cfg.CAURL + "/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("step-ca not reachable at %s: %w", cfg.CAURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("step-ca health check returned status %d", resp.StatusCode)
}
// Validate provisioner key path exists if provided
if cfg.ProvisionerKeyPath != "" {
if _, err := os.Stat(cfg.ProvisionerKeyPath); err != nil {
return fmt.Errorf("provisioner key not accessible: %w", err)
}
}
c.config = &cfg
c.logger.Info("step-ca configuration validated",
"ca_url", cfg.CAURL,
"provisioner", cfg.ProvisionerName)
return nil
}
// signRequest is the JSON body for the step-ca /sign endpoint.
type signRequest struct {
CsrPEM string `json:"csr"`
OTT string `json:"ott"` // One-Time Token (provisioner JWT)
NotBefore time.Time `json:"notBefore,omitempty"`
NotAfter time.Time `json:"notAfter,omitempty"`
}
// signResponse is the JSON response from the step-ca /sign endpoint.
type signResponse struct {
ServerPEM certificateChain `json:"serverPEM,omitempty"`
CaPEM certificateChain `json:"caPEM,omitempty"`
CertChainPEM []certBlock `json:"certChainPEM,omitempty"`
}
type certificateChain struct {
Certificate string `json:"certificate"`
}
type certBlock struct {
Certificate string `json:"certificate"`
}
// IssueCertificate submits a CSR to step-ca for signing.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing step-ca issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Generate a provisioner token (OTT) for this request
ott, err := c.generateProvisionerToken(request.CommonName, request.SANs)
if err != nil {
return nil, fmt.Errorf("failed to generate provisioner token: %w", err)
}
// Build the sign request
now := time.Now()
notAfter := now.AddDate(0, 0, c.config.ValidityDays)
signReq := signRequest{
CsrPEM: request.CSRPEM,
OTT: ott,
NotBefore: now,
NotAfter: notAfter,
}
body, err := json.Marshal(signReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal sign request: %w", err)
}
// POST /sign
signURL := c.config.CAURL + "/sign"
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")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("step-ca 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.StatusCreated && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("step-ca sign returned status %d: %s", resp.StatusCode, string(respBody))
}
// Parse response — step-ca returns the cert chain
certPEM, chainPEM, serial, certNotBefore, certNotAfter, err := parseSignResponse(respBody)
if err != nil {
return nil, fmt.Errorf("failed to parse sign response: %w", err)
}
orderID := fmt.Sprintf("stepca-%s", serial)
c.logger.Info("step-ca certificate issued",
"common_name", request.CommonName,
"serial", serial,
"not_after", certNotAfter)
return &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: chainPEM,
Serial: serial,
NotBefore: certNotBefore,
NotAfter: certNotAfter,
OrderID: orderID,
}, nil
}
// RenewCertificate renews a certificate by creating a new signing request.
// For step-ca, renewal is functionally identical to issuance.
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing step-ca 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,
})
}
// revokeRequest is the JSON body for the step-ca /revoke endpoint.
type revokeRequest struct {
Serial string `json:"serial"`
ReasonCode int `json:"reasonCode,omitempty"`
Reason string `json:"reason,omitempty"`
OTT string `json:"ott"`
Passive bool `json:"passive"` // true = don't propagate to OCSP (just mark revoked)
}
// RevokeCertificate revokes a certificate at step-ca.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing step-ca revocation request", "serial", request.Serial)
ott, err := c.generateProvisionerToken(request.Serial, nil)
if err != nil {
return fmt.Errorf("failed to generate revocation token: %w", err)
}
reason := "unspecified"
if request.Reason != nil {
reason = *request.Reason
}
revokeReq := revokeRequest{
Serial: request.Serial,
Reason: reason,
OTT: ott,
Passive: true,
}
body, err := json.Marshal(revokeReq)
if err != nil {
return fmt.Errorf("failed to marshal revoke request: %w", err)
}
revokeURL := c.config.CAURL + "/revoke"
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")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("step-ca revoke request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("step-ca revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("step-ca certificate revoked", "serial", request.Serial, "reason", reason)
return nil
}
// GetOrderStatus returns the status of a step-ca order.
// step-ca 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
}
// generateProvisionerToken creates a short-lived JWT (One-Time Token) for step-ca API calls.
// This is a minimal JWT signed with the provisioner's key.
func (c *Connector) generateProvisionerToken(subject string, sans []string) (string, error) {
// For the initial implementation, we generate a simple self-signed JWT.
// In production, the provisioner key would be loaded from the configured path.
// step-ca expects a JWT with: sub=<CN>, iss=<provisioner>, aud=<ca-url>/sign
now := time.Now()
claims := map[string]interface{}{
"sub": subject,
"iss": c.config.ProvisionerName,
"aud": c.config.CAURL + "/sign",
"nbf": now.Unix(),
"iat": now.Unix(),
"exp": now.Add(5 * time.Minute).Unix(),
"jti": generateJTI(),
"sha": c.config.ProvisionerName, // step-ca uses this for key lookup
}
if len(sans) > 0 {
claims["sans"] = sans
}
// Generate an ephemeral signing key for the token.
// In a full implementation, this would use the provisioner key from disk.
// For now, we use an ephemeral key — step-ca administrators should configure
// the provisioner to accept tokens from this key.
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", fmt.Errorf("failed to generate token signing key: %w", err)
}
return signJWT(claims, key)
}
// generateJTI creates a unique JWT ID.
func generateJTI() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// signJWT creates a minimal ES256 JWT from the given claims.
func signJWT(claims map[string]interface{}, key *ecdsa.PrivateKey) (string, error) {
// Header
header := map[string]string{
"alg": "ES256",
"typ": "JWT",
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
signingInput := headerB64 + "." + claimsB64
// Sign with ES256
hash := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, key, hash[:])
if err != nil {
return "", fmt.Errorf("failed to sign JWT: %w", err)
}
// Encode signature as fixed-size concatenation (r || s, 32 bytes each for P-256)
sig := make([]byte, 64)
rBytes := r.Bytes()
sBytes := s.Bytes()
copy(sig[32-len(rBytes):32], rBytes)
copy(sig[64-len(sBytes):64], sBytes)
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return signingInput + "." + sigB64, nil
}
// parseSignResponse extracts the certificate and chain from step-ca's /sign response.
func parseSignResponse(respBody []byte) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) {
// step-ca /sign response format:
// { "crt": "-----BEGIN CERTIFICATE-----\n...", "ca": "-----BEGIN CERTIFICATE-----\n..." }
// or
// { "serverPEM": { "certificate": "..." }, "caPEM": { "certificate": "..." } }
// or
// { "certChainPEM": [ { "certificate": "..." }, ... ] }
// Try the simple format first (crt/ca)
var simpleResp struct {
Crt string `json:"crt"`
Ca string `json:"ca"`
}
if err = json.Unmarshal(respBody, &simpleResp); err == nil && simpleResp.Crt != "" {
certPEM = simpleResp.Crt
chainPEM = simpleResp.Ca
} else {
// Try the structured format
var structResp signResponse
if err = json.Unmarshal(respBody, &structResp); err != nil {
return "", "", "", time.Time{}, time.Time{}, fmt.Errorf("failed to parse sign response: %w", err)
}
if structResp.ServerPEM.Certificate != "" {
certPEM = structResp.ServerPEM.Certificate
chainPEM = structResp.CaPEM.Certificate
} else if len(structResp.CertChainPEM) > 0 {
certPEM = structResp.CertChainPEM[0].Certificate
for i := 1; i < len(structResp.CertChainPEM); i++ {
chainPEM += structResp.CertChainPEM[i].Certificate
}
}
}
if certPEM == "" {
return "", "", "", time.Time{}, time.Time{}, fmt.Errorf("no certificate in sign response")
}
// Parse the leaf cert to extract metadata
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return "", "", "", time.Time{}, time.Time{}, fmt.Errorf("failed to decode certificate PEM")
}
cert, parseErr := x509.ParseCertificate(block.Bytes)
if parseErr != nil {
return "", "", "", time.Time{}, time.Time{}, fmt.Errorf("failed to parse certificate: %w", parseErr)
}
serial = cert.SerialNumber.String()
notBefore = cert.NotBefore
notAfter = cert.NotAfter
return certPEM, chainPEM, serial, notBefore, notAfter, nil
}
// GenerateCRL is not supported by step-ca as step-ca provides its own CRL endpoint.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("step-ca provides its own CRL endpoint; use step-ca's /crl directly")
}
// SignOCSPResponse is not supported by step-ca as step-ca provides its own OCSP responder.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("step-ca provides its own OCSP responder; use step-ca's /ocsp directly")
}
// GetCACertPEM is not directly supported; step-ca serves its own /root endpoint.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("step-ca serves its own CA certificate at /root; use step-ca's endpoint directly")
}
// GetRenewalInfo returns nil, nil as step-ca does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)