feat: M17 OpenSSL/Custom CA issuer connector + M16b CLI tool with bulk import

M17: Script-based issuer connector delegating sign/revoke/CRL to user-provided
scripts. Compatible with any CA tooling (OpenSSL, cfssl, custom PKI). Configurable
timeout, environment variable passthrough. 14 tests including timeout enforcement.

M16b: certctl-cli wraps all 76 REST API endpoints for terminal workflows. Supports
certs/agents/jobs list/get/renew/revoke/cancel, bulk PEM import with progress
reporting, server health status, table and JSON output formats. Zero external
dependencies (stdlib only). 14 tests with mock HTTP server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-23 18:12:40 -04:00
parent 9b0ff37973
commit df1aaa37f8
10 changed files with 2213 additions and 1 deletions
@@ -0,0 +1,432 @@
// 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"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// 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 executable
if _, err := os.Stat(cfg.SignScript); err != nil {
return fmt.Errorf("sign_script not accessible: %w", err)
}
// Verify revoke_script exists if specified
if cfg.RevokeScript != "" {
if _, err := os.Stat(cfg.RevokeScript); err != nil {
return fmt.Errorf("revoke_script not accessible: %w", err)
}
}
// Verify crl_script exists if specified
if cfg.CRLScript != "" {
if _, err := os.Stat(cfg.CRLScript); err != nil {
return fmt.Errorf("crl_script not accessible: %w", err)
}
}
// 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))
// 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
}
// 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
}
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
}
// --- 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, "", " ")
}
@@ -0,0 +1,558 @@
package openssl_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"log/slog"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/openssl"
)
func TestOpenSSLConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Test 1: ValidateConfig with valid config
t.Run("ValidateConfig_Success", func(t *testing.T) {
// Create a temporary directory for script files
tmpDir := t.TempDir()
// Create a minimal sign script
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
TimeoutSeconds: 30,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
// Test 2: ValidateConfig with missing sign_script
t.Run("ValidateConfig_MissingSignScript", func(t *testing.T) {
config := &openssl.Config{
SignScript: "",
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing sign_script, got nil")
}
})
// Test 3: ValidateConfig with nonexistent script path
t.Run("ValidateConfig_NonexistentScript", func(t *testing.T) {
config := &openssl.Config{
SignScript: "/nonexistent/path/to/sign.sh",
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for nonexistent script, got nil")
}
})
// Test 4: IssueCertificate with a real test CSR and mock sign script
t.Run("IssueCertificate_Success", func(t *testing.T) {
tmpDir := t.TempDir()
// Create a mock sign script that creates a self-signed cert from CSR
signScript := filepath.Join(tmpDir, "sign.sh")
mockCertPEM := generateMockCertPEM()
scriptContent := "#!/bin/sh\n" +
"CSR_FILE=\"$1\"\n" +
"CERT_FILE=\"$2\"\n" +
"cat > \"$CERT_FILE\" << 'EOF'\n" + mockCertPEM + "\nEOF\n" +
"exit 0\n"
if err := os.WriteFile(signScript, []byte(scriptContent), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
TimeoutSeconds: 30,
}
connector := openssl.New(config, logger)
// Validate config first
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
// Generate test CSR
csr, csrPEM, err := generateTestCSR("test.example.com")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
req := issuer.IssuanceRequest{
CommonName: csr.Subject.CommonName,
SANs: []string{"www.test.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.Serial == "" {
t.Error("Serial is empty")
}
if result.CertPEM == "" {
t.Error("CertPEM is empty")
}
if result.OrderID == "" {
t.Error("OrderID is empty")
}
if result.NotAfter.IsZero() {
t.Error("NotAfter is zero")
}
t.Logf("Certificate issued: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
// Test 5: IssueCertificate with sign script failure
t.Run("IssueCertificate_SignScriptFailure", func(t *testing.T) {
tmpDir := t.TempDir()
// Create a sign script that fails
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 1"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
TimeoutSeconds: 30,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
csr, csrPEM, err := generateTestCSR("test.example.com")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
req := issuer.IssuanceRequest{
CommonName: csr.Subject.CommonName,
SANs: []string{"www.test.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error from failing sign script, got nil")
}
if result != nil {
t.Error("Expected result to be nil on error")
}
})
// Test 6: IssueCertificate with timeout
t.Run("IssueCertificate_SignScriptTimeout", func(t *testing.T) {
tmpDir := t.TempDir()
// Create a sign script that takes too long
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nsleep 10\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
TimeoutSeconds: 1, // 1 second timeout
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
csr, csrPEM, err := generateTestCSR("test.example.com")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
req := issuer.IssuanceRequest{
CommonName: csr.Subject.CommonName,
SANs: []string{"www.test.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected timeout error, got nil")
}
if result != nil {
t.Error("Expected result to be nil on timeout")
}
})
// Test 7: RenewCertificate delegates to IssueCertificate
t.Run("RenewCertificate_Success", func(t *testing.T) {
tmpDir := t.TempDir()
// Create a mock sign script
signScript := filepath.Join(tmpDir, "sign.sh")
mockCertPEM := generateMockCertPEM()
scriptContent := "#!/bin/sh\n" +
"CSR_FILE=\"$1\"\n" +
"CERT_FILE=\"$2\"\n" +
"cat > \"$CERT_FILE\" << 'EOF'\n" + mockCertPEM + "\nEOF\n" +
"exit 0\n"
if err := os.WriteFile(signScript, []byte(scriptContent), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
TimeoutSeconds: 30,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
csr, csrPEM, err := generateTestCSR("test.example.com")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
renewReq := issuer.RenewalRequest{
CommonName: csr.Subject.CommonName,
SANs: []string{"www.test.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.Serial == "" {
t.Error("Serial is empty")
}
t.Logf("Certificate renewed: serial=%s", result.Serial)
})
// Test 8: RevokeCertificate without revoke script configured
t.Run("RevokeCertificate_NoScript", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
// RevokeScript not set
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
revokeReq := issuer.RevocationRequest{
Serial: "test-serial-12345",
}
// Should return nil (no-op) when revoke script not configured
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
})
// Test 9: RevokeCertificate with revoke script
t.Run("RevokeCertificate_WithScript", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
revokeScript := filepath.Join(tmpDir, "revoke.sh")
if err := os.WriteFile(revokeScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create revoke script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
RevokeScript: revokeScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
revokeReq := issuer.RevocationRequest{
Serial: "test-serial-12345",
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
})
// Test 10: GetOrderStatus always returns "completed"
t.Run("GetOrderStatus", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
status, err := connector.GetOrderStatus(ctx, "openssl-12345")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
t.Logf("Order status: %s", status.Status)
})
// Test 11: GenerateCRL without CRL script configured
t.Run("GenerateCRL_NoScript", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
// CRLScript not set
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
crl, err := connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
if err != nil {
t.Fatalf("GenerateCRL failed: %v", err)
}
// Should return nil when CRL script not configured
if crl != nil {
t.Error("Expected nil CRL when CRL script not configured")
}
})
// Test 12: GenerateCRL with CRL script
t.Run("GenerateCRL_WithScript", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
crlScript := filepath.Join(tmpDir, "crl.sh")
scriptContent := "#!/bin/sh\n" +
"SERIALS_FILE=\"$1\"\n" +
"CRL_FILE=\"$2\"\n" +
"echo 'test-crl-content' > \"$CRL_FILE\"\n" +
"exit 0\n"
if err := os.WriteFile(crlScript, []byte(scriptContent), 0755); err != nil {
t.Fatalf("Failed to create CRL script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
CRLScript: crlScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
crl, err := connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
if err != nil {
t.Fatalf("GenerateCRL failed: %v", err)
}
if crl == nil {
t.Error("Expected CRL, got nil")
}
if len(crl) == 0 {
t.Error("Expected non-empty CRL")
}
})
// Test 13: SignOCSPResponse returns nil (not supported)
t.Run("SignOCSPResponse_NotSupported", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{})
if err != nil {
t.Fatalf("SignOCSPResponse failed: %v", err)
}
if resp != nil {
t.Error("Expected nil OCSP response (not supported)")
}
})
// Test 14: Default timeout
t.Run("DefaultTimeout", func(t *testing.T) {
tmpDir := t.TempDir()
signScript := filepath.Join(tmpDir, "sign.sh")
if err := os.WriteFile(signScript, []byte("#!/bin/sh\nexit 0"), 0755); err != nil {
t.Fatalf("Failed to create sign script: %v", err)
}
config := &openssl.Config{
SignScript: signScript,
TimeoutSeconds: 0, // Should default to 30
}
connector := openssl.New(config, logger)
rawConfig, _ := json.Marshal(config)
if err := connector.ValidateConfig(ctx, rawConfig); err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
// If timeout is 30 seconds, the config should validate without errors
// (we can't easily test the actual timeout value without accessing private fields)
t.Log("Default timeout configured (should be 30 seconds)")
})
}
// --- Test Helpers ---
// generateTestCSR creates a test Certificate Signing Request.
func generateTestCSR(cn string) (*x509.CertificateRequest, string, error) {
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, "", err
}
subject := pkix.Name{
CommonName: cn,
}
csrTemplate := x509.CertificateRequest{
Subject: subject,
DNSNames: []string{cn, "www." + cn},
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, privKey)
if err != nil {
return nil, "", err
}
csr, err := x509.ParseCertificateRequest(csrBytes)
if err != nil {
return nil, "", err
}
csrPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
})
return csr, string(csrPEM), nil
}
// generateMockCertPEM creates a self-signed certificate for testing.
func generateMockCertPEM() string {
privKey, _ := rsa.GenerateKey(rand.Reader, 2048)
serialNumber := big.NewInt(1234567890)
subject := pkix.Name{
CommonName: "test.example.com",
}
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: subject,
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(0, 0, 90),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"test.example.com", "www.test.example.com"},
}
certBytes, _ := x509.CreateCertificate(rand.Reader, template, template, privKey.Public(), privKey)
return string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
}))
}