Files
certctl/internal/connector/issuer/stepca/stepca.go
T
shankar0123 5dc698307b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit bc6039a (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00

562 lines
19 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/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
"github.com/certctl-io/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 = <ca-url>/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)