Files
certctl/internal/connector/issuer/openssl/openssl.go
T
shankar0123 7cb453a336 chore(fmt): repo-wide gofmt -w sweep — close drift surfaced by ci-pipeline-cleanup Phase 4
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.

Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.

The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
2026-04-30 22:33:57 +00:00

498 lines
17 KiB
Go

// Package openssl implements the issuer.Connector interface for custom CA integrations.
//
// This connector delegates certificate signing to user-provided scripts/commands.
// It allows operators to use their existing CA tooling (OpenSSL, cfssl, custom scripts, etc.)
// as the signing backend for certctl.
//
// Configuration:
//
// SignScript: path to a script/command that signs CSRs.
// Called as: <sign_script> <csr_file> <cert_output_file>
// The script receives the CSR PEM as a temp file, and must write the signed cert PEM to the output file.
// Exit 0 = success, non-zero = failure (stderr captured as error message).
//
// RevokeScript: path to a script/command that revokes certificates (optional).
// Called as: <revoke_script> <serial> <reason>
// Optional — if empty, revocation returns "not supported".
//
// CRLScript: path to a script/command that generates a CRL (optional).
// Called as: <crl_script> <revoked_serials_json_file> <crl_output_file>
// Optional — if empty, CRL generation returns nil.
//
// TimeoutSeconds: max time to wait for script execution (default 30).
package openssl
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/validation"
)
// Config represents the OpenSSL/Custom CA issuer connector configuration.
type Config struct {
// SignScript is the path to a script/command that signs CSRs.
// Called as: <sign_script> <csr_file> <cert_output_file>
// The script receives the CSR PEM as a temp file, and must write the signed cert PEM to the output file.
// Exit 0 = success, non-zero = failure (stderr captured as error message).
SignScript string `json:"sign_script"`
// RevokeScript is the path to a script/command that revokes certificates.
// Called as: <revoke_script> <serial> <reason>
// Optional — if empty, revocation returns "not supported".
RevokeScript string `json:"revoke_script,omitempty"`
// CRLScript is the path to a script/command that generates a CRL.
// Called as: <crl_script> <revoked_serials_json_file> <crl_output_file>
// Optional — if empty, CRL generation returns nil.
CRLScript string `json:"crl_script,omitempty"`
// TimeoutSeconds is the max time to wait for script execution.
// Defaults to 30.
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}
// Connector implements the issuer.Connector interface for custom CA signing via scripts.
type Connector struct {
config *Config
logger *slog.Logger
timeout time.Duration
}
// New creates a new OpenSSL/Custom CA connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
if config == nil {
config = &Config{}
}
timeout := time.Duration(config.TimeoutSeconds) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
return &Connector{
config: config,
logger: logger,
timeout: timeout,
}
}
// ValidateConfig validates the OpenSSL/Custom CA configuration.
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 OpenSSL/Custom CA config: %w", err)
}
// SignScript is required
if cfg.SignScript == "" {
return fmt.Errorf("sign_script is required")
}
// Verify sign_script exists and is a regular file
if info, err := os.Stat(cfg.SignScript); err != nil {
return fmt.Errorf("sign_script not accessible: %w", err)
} else if !info.Mode().IsRegular() {
return fmt.Errorf("sign_script must be a regular file, got %s", info.Mode())
}
// Verify revoke_script exists and is a regular file if specified
if cfg.RevokeScript != "" {
if info, err := os.Stat(cfg.RevokeScript); err != nil {
return fmt.Errorf("revoke_script not accessible: %w", err)
} else if !info.Mode().IsRegular() {
return fmt.Errorf("revoke_script must be a regular file, got %s", info.Mode())
}
}
// Verify crl_script exists and is a regular file if specified
if cfg.CRLScript != "" {
if info, err := os.Stat(cfg.CRLScript); err != nil {
return fmt.Errorf("crl_script not accessible: %w", err)
} else if !info.Mode().IsRegular() {
return fmt.Errorf("crl_script must be a regular file, got %s", info.Mode())
}
}
// Update connector config
c.config = &cfg
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
c.timeout = timeout
c.logger.Info("OpenSSL/Custom CA configuration validated",
"sign_script", cfg.SignScript,
"has_revoke_script", cfg.RevokeScript != "",
"has_crl_script", cfg.CRLScript != "",
"timeout_seconds", c.timeout.Seconds())
return nil
}
// IssueCertificate issues a new certificate by calling the sign script.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing custom CA issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// MaxTTLSeconds is advisory for script-based issuers — the sign script controls validity.
// Log a warning so operators know the profile TTL cap isn't enforced server-side.
if request.MaxTTLSeconds > 0 {
c.logger.Warn("MaxTTLSeconds specified but OpenSSL/custom CA delegates signing to external script; TTL cap is advisory only",
"max_ttl_seconds", request.MaxTTLSeconds,
"common_name", request.CommonName)
}
// Write CSR to a temporary file
csrFile, err := c.writeTempFile([]byte(request.CSRPEM), "csr-")
if err != nil {
c.logger.Error("failed to write CSR temp file", "error", err)
return nil, fmt.Errorf("failed to write CSR temp file: %w", err)
}
defer os.Remove(csrFile)
// Create temp file for cert output
certFile := filepath.Join(filepath.Dir(csrFile), "cert-"+filepath.Base(csrFile))
defer os.Remove(certFile)
// Call sign script
if err := c.callSignScript(ctx, csrFile, certFile); err != nil {
c.logger.Error("sign script failed", "error", err)
return nil, fmt.Errorf("sign script failed: %w", err)
}
// Read the signed certificate
certPEM, err := os.ReadFile(certFile)
if err != nil {
c.logger.Error("failed to read signed certificate", "error", err)
return nil, fmt.Errorf("failed to read signed certificate: %w", err)
}
// Parse the certificate to extract metadata
cert, serial, err := c.parseCertificate(certPEM)
if err != nil {
c.logger.Error("failed to parse signed certificate", "error", err)
return nil, fmt.Errorf("failed to parse signed certificate: %w", err)
}
orderID := fmt.Sprintf("openssl-%s", serial)
result := &issuer.IssuanceResult{
CertPEM: string(certPEM),
ChainPEM: "", // Custom CA connectors typically don't provide chain; operators must configure separately
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}
c.logger.Info("certificate issued successfully",
"serial", serial,
"common_name", request.CommonName,
"not_after", cert.NotAfter)
return result, nil
}
// RenewCertificate renews a certificate by issuing a new one with the same identifiers.
// For custom CA connectors, this is functionally identical to IssueCertificate.
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing custom CA renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Write CSR to a temporary file
csrFile, err := c.writeTempFile([]byte(request.CSRPEM), "csr-")
if err != nil {
c.logger.Error("failed to write CSR temp file", "error", err)
return nil, fmt.Errorf("failed to write CSR temp file: %w", err)
}
defer os.Remove(csrFile)
// Create temp file for cert output
certFile := filepath.Join(filepath.Dir(csrFile), "cert-"+filepath.Base(csrFile))
defer os.Remove(certFile)
// Call sign script
if err := c.callSignScript(ctx, csrFile, certFile); err != nil {
c.logger.Error("sign script failed", "error", err)
return nil, fmt.Errorf("sign script failed: %w", err)
}
// Read the signed certificate
certPEM, err := os.ReadFile(certFile)
if err != nil {
c.logger.Error("failed to read signed certificate", "error", err)
return nil, fmt.Errorf("failed to read signed certificate: %w", err)
}
// Parse the certificate to extract metadata
cert, serial, err := c.parseCertificate(certPEM)
if err != nil {
c.logger.Error("failed to parse signed certificate", "error", err)
return nil, fmt.Errorf("failed to parse signed certificate: %w", err)
}
// Preserve order ID if provided
orderID := fmt.Sprintf("openssl-%s", serial)
if request.OrderID != nil {
orderID = *request.OrderID
}
result := &issuer.IssuanceResult{
CertPEM: string(certPEM),
ChainPEM: "",
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}
c.logger.Info("certificate renewed successfully",
"serial", serial,
"common_name", request.CommonName,
"not_after", cert.NotAfter)
return result, nil
}
// hexSerialRegex validates that a serial number contains only hexadecimal characters.
// Certificate serial numbers are integers represented in hex (RFC 5280).
var hexSerialRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
// validateSerial validates a certificate serial number for safe use in shell commands.
// Serial numbers must be non-empty, hex-only strings with no shell metacharacters.
func validateSerial(serial string) error {
if serial == "" {
return fmt.Errorf("serial number cannot be empty")
}
if !hexSerialRegex.MatchString(serial) {
return fmt.Errorf("serial number %q contains non-hex characters (expected ^[0-9a-fA-F]+$)", serial)
}
if err := validation.ValidateShellCommand(serial); err != nil {
return fmt.Errorf("serial number failed shell safety validation: %w", err)
}
return nil
}
// validateRevocationReason validates a revocation reason against RFC 5280 reason codes.
func validateRevocationReason(reason string) error {
if !domain.IsValidRevocationReason(reason) {
return fmt.Errorf("invalid revocation reason %q (must be a valid RFC 5280 reason code)", reason)
}
if err := validation.ValidateShellCommand(reason); err != nil {
return fmt.Errorf("revocation reason failed shell safety validation: %w", err)
}
return nil
}
// RevokeCertificate revokes a certificate by calling the revoke script if configured.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
if c.config.RevokeScript == "" {
c.logger.Warn("revocation not supported (revoke_script not configured)", "serial", request.Serial)
return nil // No-op if revoke script not configured
}
reason := "unspecified"
if request.Reason != nil {
reason = *request.Reason
}
// Validate serial number (hex-only) and reason code (RFC 5280) before shell execution
if err := validateSerial(request.Serial); err != nil {
return fmt.Errorf("revocation input validation failed: %w", err)
}
if err := validateRevocationReason(reason); err != nil {
return fmt.Errorf("revocation input validation failed: %w", err)
}
c.logger.Info("revoking certificate via revoke script",
"serial", request.Serial,
"reason", reason)
// Call revoke script: <revoke_script> <serial> <reason>
cmd := exec.CommandContext(ctx, c.config.RevokeScript, request.Serial, reason)
cmd.Env = os.Environ() // Inherit environment
if err := cmd.Run(); err != nil {
// Log but don't fail — revocation is best-effort
c.logger.Warn("revoke script completed with error",
"serial", request.Serial,
"error", err)
// Return nil to indicate best-effort success
}
c.logger.Info("certificate revoked",
"serial", request.Serial,
"reason", reason)
return nil
}
// GetOrderStatus returns the status of an issuance or renewal order.
// For custom CA connectors, orders complete immediately, so this always returns "completed" status.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Info("fetching custom CA order status", "order_id", orderID)
// Custom CA orders complete immediately
status := &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
UpdatedAt: time.Now(),
}
return status, nil
}
// GenerateCRL generates a DER-encoded X.509 CRL by calling the CRL script if configured.
// Returns nil if the CRL script is not configured.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
if c.config.CRLScript == "" {
c.logger.Debug("CRL generation not supported (crl_script not configured)")
return nil, nil
}
c.logger.Info("generating CRL via crl script", "revoked_count", len(revokedCerts))
// Write revoked serials to a temporary JSON file
serialsJSON, err := c.marshalRevokedSerials(revokedCerts)
if err != nil {
c.logger.Error("failed to marshal revoked serials", "error", err)
return nil, fmt.Errorf("failed to marshal revoked serials: %w", err)
}
serialsFile, err := c.writeTempFile(serialsJSON, "serials-")
if err != nil {
c.logger.Error("failed to write revoked serials temp file", "error", err)
return nil, fmt.Errorf("failed to write revoked serials temp file: %w", err)
}
defer os.Remove(serialsFile)
// Create temp file for CRL output
crlFile := filepath.Join(filepath.Dir(serialsFile), "crl-"+filepath.Base(serialsFile))
defer os.Remove(crlFile)
// Call CRL script: <crl_script> <revoked_serials_json_file> <crl_output_file>
cmd := exec.CommandContext(ctx, c.config.CRLScript, serialsFile, crlFile)
cmd.Env = os.Environ()
if err := cmd.Run(); err != nil {
c.logger.Error("crl script failed", "error", err)
return nil, fmt.Errorf("crl script failed: %w", err)
}
// Read the generated CRL
crlDER, err := os.ReadFile(crlFile)
if err != nil {
c.logger.Error("failed to read generated CRL", "error", err)
return nil, fmt.Errorf("failed to read generated CRL: %w", err)
}
c.logger.Info("CRL generated successfully", "crl_size", len(crlDER))
return crlDER, nil
}
// SignOCSPResponse signs an OCSP response.
// Custom CA connectors don't support OCSP, so this returns nil.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
c.logger.Debug("OCSP signing not supported by custom CA connector")
return nil, nil
}
// GetCACertPEM is not supported by the custom CA connector (no CA cert access).
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("custom CA connector does not provide CA certificate access")
}
// GetRenewalInfo returns nil, nil as the custom CA connector does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// --- Helper Methods ---
// writeTempFile writes data to a temporary file and returns its path.
func (c *Connector) writeTempFile(data []byte, prefix string) (string, error) {
f, err := os.CreateTemp("", prefix+"*.pem")
if err != nil {
return "", err
}
defer f.Close()
if _, err := f.Write(data); err != nil {
os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
// callSignScript calls the sign script with CSR and cert output file paths.
// Returns the script's error message if execution fails.
func (c *Connector) callSignScript(ctx context.Context, csrFile, certFile string) error {
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
// Call sign script: <sign_script> <csr_file> <cert_output_file>
cmd := exec.CommandContext(ctx, c.config.SignScript, csrFile, certFile)
cmd.Env = os.Environ() // Inherit environment
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("script exited with error: %w (output: %s)", err, string(output))
}
return nil
}
// parseCertificate parses a PEM-encoded certificate and extracts serial and X.509 cert.
func (c *Connector) parseCertificate(certPEM []byte) (*x509.Certificate, string, error) {
block, _ := pem.Decode(certPEM)
if block == nil || block.Type != "CERTIFICATE" {
return nil, "", fmt.Errorf("invalid certificate PEM format")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, "", fmt.Errorf("failed to parse certificate: %w", err)
}
serial := cert.SerialNumber.String()
return cert, serial, nil
}
// marshalRevokedSerials converts revoked certs to JSON format for the CRL script.
// Format: [{"serial": "...", "revoked_at": "...", "reason_code": ...}, ...]
func (c *Connector) marshalRevokedSerials(revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
type RevokedEntry struct {
Serial string `json:"serial"`
RevokedAt string `json:"revoked_at"`
ReasonCode int `json:"reason_code"`
}
entries := make([]RevokedEntry, len(revokedCerts))
for i, rc := range revokedCerts {
entries[i] = RevokedEntry{
Serial: rc.SerialNumber.String(),
RevokedAt: rc.RevokedAt.Format(time.RFC3339),
ReasonCode: rc.ReasonCode,
}
}
return json.MarshalIndent(entries, "", " ")
}