// 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/tls" "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. // If RootCertPath is set, the HTTP client will trust that CA certificate for TLS connections. // Otherwise, the system trust store is used (which works if setup-trust.sh has run). func New(config *Config, logger *slog.Logger) *Connector { // Don't default ValidityDays — let step-ca use its own default duration. // Operators can explicitly set ValidityDays if their step-ca is configured // with longer max durations. A zero value means "omit from sign request." httpClient := &http.Client{Timeout: 30 * time.Second} // Load custom root CA cert if provided if config != nil && config.RootCertPath != "" { rootPEM, err := os.ReadFile(config.RootCertPath) if err == nil { pool := x509.NewCertPool() if pool.AppendCertsFromPEM(rootPEM) { httpClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: pool, }, } logger.Info("step-ca custom root CA loaded", "path", config.RootCertPath) } } else { logger.Warn("failed to read step-ca root cert, using system trust store", "path", config.RootCertPath, "error", err) } } return &Connector{ config: config, logger: logger, httpClient: httpClient, } } // 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") } // Don't default ValidityDays — 0 means "let step-ca use its own default duration" // 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. // When ValidityDays is 0 (default), omit NotBefore/NotAfter so step-ca uses its // own default duration (typically 24h). The signRequest struct has omitempty on // both time fields, so zero-value time.Time{} gets stripped from the JSON. signReq := signRequest{ CsrPEM: request.CSRPEM, OTT: ott, } if c.config.ValidityDays > 0 || request.MaxTTLSeconds > 0 { now := time.Now() signReq.NotBefore = now if c.config.ValidityDays > 0 { signReq.NotAfter = now.AddDate(0, 0, c.config.ValidityDays) } // Cap validity to MaxTTLSeconds if profile specifies a maximum if request.MaxTTLSeconds > 0 { maxNotAfter := now.Add(time.Duration(request.MaxTTLSeconds) * time.Second) if signReq.NotAfter.IsZero() || maxNotAfter.Before(signReq.NotAfter) { signReq.NotAfter = maxNotAfter } } } 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, MaxTTLSeconds: request.MaxTTLSeconds, }) } // 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. // The JWT is signed with the provisioner's private key (loaded from the encrypted JWE file // at ProvisionerKeyPath and decrypted with ProvisionerPassword). func (c *Connector) generateProvisionerToken(subject string, sans []string) (string, error) { var key *ecdsa.PrivateKey var kid string if c.config.ProvisionerKeyPath != "" { // Production: load and decrypt the real provisioner key from disk var err error key, kid, err = c.loadProvisionerKey() if err != nil { return "", fmt.Errorf("failed to load provisioner key: %w", err) } } else { // Fallback: generate an ephemeral key (for testing or when key path not configured). // This won't authenticate with a real step-ca server, but allows the connector // to function against mock servers in tests. c.logger.Warn("no provisioner key path configured, using ephemeral key (will not work with real step-ca)") var err error key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return "", fmt.Errorf("failed to generate ephemeral key: %w", err) } kid = "ephemeral" } now := time.Now() // step-ca expects: aud = /1.0/sign (the sign endpoint audience) claims := map[string]interface{}{ "sub": subject, "iss": c.config.ProvisionerName, "aud": c.config.CAURL + "/1.0/sign", "nbf": now.Unix(), "iat": now.Unix(), "exp": now.Add(5 * time.Minute).Unix(), "jti": generateJTI(), "sha": kid, // step-ca uses this to look up the provisioner by key fingerprint } if len(sans) > 0 { claims["sans"] = sans } return signJWTWithKID(claims, key, kid) } // loadProvisionerKey loads and decrypts the step-ca provisioner key from disk. // Returns the ECDSA private key and the key ID (JWK thumbprint). func (c *Connector) loadProvisionerKey() (*ecdsa.PrivateKey, string, error) { if c.config.ProvisionerKeyPath == "" { return nil, "", fmt.Errorf("provisioner_key_path is required for step-ca JWK authentication") } jweData, err := os.ReadFile(c.config.ProvisionerKeyPath) if err != nil { return nil, "", fmt.Errorf("failed to read provisioner key file %s: %w", c.config.ProvisionerKeyPath, err) } password := c.config.ProvisionerPassword if password == "" { return nil, "", fmt.Errorf("provisioner_password is required to decrypt the provisioner key") } key, kid, err := decryptProvisionerKey(jweData, password) if err != nil { return nil, "", fmt.Errorf("failed to decrypt provisioner key: %w", err) } c.logger.Info("provisioner key loaded and decrypted", "key_path", c.config.ProvisionerKeyPath, "kid", kid) return key, kid, nil } // generateJTI creates a unique JWT ID. func generateJTI() string { b := make([]byte, 16) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } // signJWTWithKID creates an ES256 JWT with a key ID in the header. func signJWTWithKID(claims map[string]interface{}, key *ecdsa.PrivateKey, kid string) (string, error) { // Header with kid so step-ca can look up the provisioner header := map[string]string{ "alg": "ES256", "typ": "JWT", "kid": kid, } return signJWTRaw(claims, key, header) } // signJWTRaw creates an ES256 JWT from the given claims and header. func signJWTRaw(claims map[string]interface{}, key *ecdsa.PrivateKey, header map[string]string) (string, error) { 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)