// 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=, iss=, aud=/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)