feat: M12 — sub-CA mode, ACME DNS-01 challenges, step-ca issuer connector

Sub-CA mode: Local CA loads CA cert+key from disk (CERTCTL_CA_CERT_PATH +
CERTCTL_CA_KEY_PATH) to operate as subordinate CA under enterprise root
(e.g., ADCS). Supports RSA, ECDSA, PKCS#8 keys. Validates IsCA and
KeyUsageCertSign. Falls back to self-signed when paths unset.

DNS-01 challenges: Pluggable DNSSolver interface with script-based hook
implementation. User-provided scripts create/cleanup _acme-challenge TXT
records for any DNS provider. Configurable propagation wait. Enables
wildcard certs and non-HTTP-accessible hosts.

step-ca connector: Smallstep private CA via native /sign API with JWK
provisioner auth. Issuance, renewal, revocation. Registered as iss-stepca.

23 new tests across 3 files. CI test path widened to ./internal/connector/issuer/...

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-21 22:55:50 -04:00
parent d7a4d40d47
commit f5fed74d6f
14 changed files with 1827 additions and 45 deletions
+33
View File
@@ -19,6 +19,7 @@ type Config struct {
RateLimit RateLimitConfig
CORS CORSConfig
Keygen KeygenConfig
CA CAConfig
}
// KeygenConfig controls where private keys are generated.
@@ -29,6 +30,34 @@ type KeygenConfig struct {
Mode string
}
// CAConfig controls the Local CA's operating mode.
type CAConfig struct {
// CertPath is the path to a PEM-encoded CA certificate for sub-CA mode.
// When set with KeyPath, the Local CA loads this cert instead of generating a self-signed root.
CertPath string
// KeyPath is the path to a PEM-encoded CA private key for sub-CA mode.
// Supports RSA, ECDSA, and PKCS#8 encoded keys.
KeyPath string
}
// StepCAConfig contains step-ca issuer connector configuration.
type StepCAConfig struct {
URL string
ProvisionerName string
ProvisionerKeyPath string
ProvisionerPassword string
}
// ACMEConfig contains ACME issuer connector configuration.
type ACMEConfig struct {
DirectoryURL string
Email string
ChallengeType string // "http-01" (default) or "dns-01"
DNSPresentScript string
DNSCleanUpScript string
}
// ServerConfig contains HTTP server configuration.
type ServerConfig struct {
Host string
@@ -113,6 +142,10 @@ func Load() (*Config, error) {
Keygen: KeygenConfig{
Mode: getEnv("CERTCTL_KEYGEN_MODE", "agent"),
},
CA: CAConfig{
CertPath: getEnv("CERTCTL_CA_CERT_PATH", ""),
KeyPath: getEnv("CERTCTL_CA_KEY_PATH", ""),
},
}
if err := cfg.Validate(); err != nil {
+158 -5
View File
@@ -27,6 +27,22 @@ type Config struct {
EABKid string `json:"eab_kid,omitempty"` // External Account Binding Key ID (for some CAs)
EABHmac string `json:"eab_hmac,omitempty"` // External Account Binding HMAC Key
HTTPPort int `json:"http_port,omitempty"` // Port for HTTP-01 challenge server (default: 80)
// ChallengeType selects the ACME challenge method: "http-01" (default) or "dns-01".
// DNS-01 is required for wildcard certificates (*.example.com).
ChallengeType string `json:"challenge_type,omitempty"`
// DNSPresentScript is the path to a script that creates DNS TXT records (dns-01 only).
// The script receives CERTCTL_DNS_DOMAIN, CERTCTL_DNS_FQDN, CERTCTL_DNS_VALUE, CERTCTL_DNS_TOKEN.
DNSPresentScript string `json:"dns_present_script,omitempty"`
// DNSCleanUpScript is the path to a script that removes DNS TXT records (dns-01 only).
// Optional — if not set, records are not cleaned up automatically.
DNSCleanUpScript string `json:"dns_cleanup_script,omitempty"`
// DNSPropagationWait is how long to wait (in seconds) after creating the TXT record
// before telling the CA to validate. Defaults to 30 seconds.
DNSPropagationWait int `json:"dns_propagation_wait,omitempty"`
}
// Connector implements the issuer.Connector interface for ACME-compatible CAs
@@ -46,18 +62,40 @@ type Connector struct {
// HTTP-01 challenge solver state
challengeMu sync.RWMutex
challengeTokens map[string]string // token → key authorization
// DNS-01 challenge solver (nil if using HTTP-01)
dnsSolver DNSSolver
}
// New creates a new ACME connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
if config != nil && config.HTTPPort == 0 {
config.HTTPPort = 80
if config != nil {
if config.HTTPPort == 0 {
config.HTTPPort = 80
}
if config.ChallengeType == "" {
config.ChallengeType = "http-01"
}
if config.DNSPropagationWait == 0 {
config.DNSPropagationWait = 30
}
}
return &Connector{
c := &Connector{
config: config,
logger: logger,
challengeTokens: make(map[string]string),
}
// Initialize DNS solver if dns-01 challenge type is configured
if config != nil && config.ChallengeType == "dns-01" && config.DNSPresentScript != "" {
c.dnsSolver = NewScriptDNSSolver(config.DNSPresentScript, config.DNSCleanUpScript, logger)
logger.Info("DNS-01 challenge solver configured",
"present_script", config.DNSPresentScript,
"cleanup_script", config.DNSCleanUpScript)
}
return c
}
// ValidateConfig checks that the ACME directory URL is reachable and valid.
@@ -98,8 +136,33 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
cfg.HTTPPort = 80
}
if cfg.ChallengeType == "" {
cfg.ChallengeType = "http-01"
}
// Validate challenge type
if cfg.ChallengeType != "http-01" && cfg.ChallengeType != "dns-01" {
return fmt.Errorf("invalid challenge_type: %s (must be http-01 or dns-01)", cfg.ChallengeType)
}
// DNS-01 requires a present script
if cfg.ChallengeType == "dns-01" && cfg.DNSPresentScript == "" {
return fmt.Errorf("dns_present_script is required for dns-01 challenge type")
}
if cfg.DNSPropagationWait == 0 {
cfg.DNSPropagationWait = 30
}
c.config = &cfg
c.logger.Info("ACME configuration validated")
// Re-initialize DNS solver if switching to dns-01
if cfg.ChallengeType == "dns-01" && cfg.DNSPresentScript != "" {
c.dnsSolver = NewScriptDNSSolver(cfg.DNSPresentScript, cfg.DNSCleanUpScript, c.logger)
}
c.logger.Info("ACME configuration validated",
"challenge_type", cfg.ChallengeType)
return nil
}
@@ -271,8 +334,17 @@ func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer
return status, nil
}
// solveAuthorizations processes all authorization URLs and solves their HTTP-01 challenges.
// solveAuthorizations processes all authorization URLs and solves their challenges.
// Supports both HTTP-01 and DNS-01 challenge types based on configuration.
func (c *Connector) solveAuthorizations(ctx context.Context, authzURLs []string) error {
if c.config.ChallengeType == "dns-01" {
return c.solveAuthorizationsDNS01(ctx, authzURLs)
}
return c.solveAuthorizationsHTTP01(ctx, authzURLs)
}
// solveAuthorizationsHTTP01 solves challenges using the HTTP-01 method.
func (c *Connector) solveAuthorizationsHTTP01(ctx context.Context, authzURLs []string) error {
// Start the challenge server
srv, err := c.startChallengeServer()
if err != nil {
@@ -344,6 +416,87 @@ func (c *Connector) solveAuthorizations(ctx context.Context, authzURLs []string)
return nil
}
// solveAuthorizationsDNS01 solves challenges using the DNS-01 method.
// DNS-01 is required for wildcard certificates (*.example.com) and works
// when the server is not publicly reachable on port 80.
func (c *Connector) solveAuthorizationsDNS01(ctx context.Context, authzURLs []string) error {
if c.dnsSolver == nil {
return fmt.Errorf("DNS-01 challenge type configured but no DNS solver available")
}
for _, authzURL := range authzURLs {
authz, err := c.client.GetAuthorization(ctx, authzURL)
if err != nil {
return fmt.Errorf("failed to get authorization %s: %w", authzURL, err)
}
if authz.Status == acme.StatusValid {
continue
}
// Find the DNS-01 challenge
var dnsChallenge *acme.Challenge
for _, ch := range authz.Challenges {
if ch.Type == "dns-01" {
dnsChallenge = ch
break
}
}
if dnsChallenge == nil {
return fmt.Errorf("no DNS-01 challenge found for %s", authz.Identifier.Value)
}
// Compute the DNS-01 key authorization (base64url-encoded SHA-256 digest)
keyAuth, err := c.client.DNS01ChallengeRecord(dnsChallenge.Token)
if err != nil {
return fmt.Errorf("failed to compute DNS-01 key authorization: %w", err)
}
domain := authz.Identifier.Value
c.logger.Info("presenting DNS-01 challenge",
"domain", domain,
"token", dnsChallenge.Token)
// Create the DNS TXT record
if err := c.dnsSolver.Present(ctx, domain, dnsChallenge.Token, keyAuth); err != nil {
return fmt.Errorf("failed to present DNS record for %s: %w", domain, err)
}
// Wait for DNS propagation
propagationWait := time.Duration(c.config.DNSPropagationWait) * time.Second
c.logger.Info("waiting for DNS propagation",
"domain", domain,
"wait_seconds", c.config.DNSPropagationWait)
time.Sleep(propagationWait)
// Tell the CA we're ready
if _, err := c.client.Accept(ctx, dnsChallenge); err != nil {
// Clean up even on failure
_ = c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth)
return fmt.Errorf("failed to accept DNS-01 challenge: %w", err)
}
// Wait for authorization to be valid
if _, err := c.client.WaitAuthorization(ctx, authzURL); err != nil {
_ = c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth)
return fmt.Errorf("DNS-01 authorization failed for %s: %w", domain, err)
}
c.logger.Info("DNS-01 authorization validated", "domain", domain)
// Clean up the DNS record
if err := c.dnsSolver.CleanUp(ctx, domain, dnsChallenge.Token, keyAuth); err != nil {
c.logger.Warn("failed to clean up DNS record (non-fatal)",
"domain", domain,
"error", err)
}
}
return nil
}
// startChallengeServer starts an HTTP server that responds to ACME HTTP-01 challenges.
// It listens on the configured HTTP port and serves challenge tokens at
// /.well-known/acme-challenge/{token}.
+110
View File
@@ -0,0 +1,110 @@
package acme
import (
"context"
"fmt"
"log/slog"
"os/exec"
"time"
)
// DNSSolver defines the interface for DNS-01 challenge provisioning.
// Implementations create and clean up DNS TXT records for ACME validation.
type DNSSolver interface {
// Present creates a DNS TXT record for the given domain with the given value.
// The FQDN will be _acme-challenge.<domain>.
Present(ctx context.Context, domain, token, keyAuth string) error
// CleanUp removes the DNS TXT record created by Present.
CleanUp(ctx context.Context, domain, token, keyAuth string) error
}
// ScriptDNSSolver implements DNSSolver by executing external scripts.
// This provides maximum flexibility: users supply their own scripts for
// whatever DNS provider they use (Cloudflare, Route53, Azure DNS, etc.).
//
// The scripts receive these environment variables:
//
// CERTCTL_DNS_DOMAIN — the domain being validated (e.g., "example.com")
// CERTCTL_DNS_FQDN — the full record name (e.g., "_acme-challenge.example.com")
// CERTCTL_DNS_VALUE — the TXT record value (key authorization digest)
// CERTCTL_DNS_TOKEN — the ACME challenge token
//
// The present script must create the TXT record and exit 0.
// The cleanup script must remove the TXT record and exit 0.
type ScriptDNSSolver struct {
PresentScript string // Path to script that creates the TXT record
CleanUpScript string // Path to script that removes the TXT record
Timeout time.Duration
Logger *slog.Logger
}
// NewScriptDNSSolver creates a script-based DNS solver.
func NewScriptDNSSolver(presentScript, cleanUpScript string, logger *slog.Logger) *ScriptDNSSolver {
return &ScriptDNSSolver{
PresentScript: presentScript,
CleanUpScript: cleanUpScript,
Timeout: 120 * time.Second,
Logger: logger,
}
}
// Present executes the present script to create a DNS TXT record.
func (s *ScriptDNSSolver) Present(ctx context.Context, domain, token, keyAuth string) error {
if s.PresentScript == "" {
return fmt.Errorf("DNS present script not configured")
}
fqdn := "_acme-challenge." + domain
s.Logger.Info("creating DNS TXT record via script",
"domain", domain,
"fqdn", fqdn,
"script", s.PresentScript)
return s.runScript(ctx, s.PresentScript, domain, fqdn, token, keyAuth)
}
// CleanUp executes the cleanup script to remove a DNS TXT record.
func (s *ScriptDNSSolver) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
if s.CleanUpScript == "" {
s.Logger.Warn("DNS cleanup script not configured, skipping cleanup", "domain", domain)
return nil
}
fqdn := "_acme-challenge." + domain
s.Logger.Info("removing DNS TXT record via script",
"domain", domain,
"fqdn", fqdn,
"script", s.CleanUpScript)
return s.runScript(ctx, s.CleanUpScript, domain, fqdn, token, keyAuth)
}
// runScript executes a DNS hook script with the appropriate environment variables.
func (s *ScriptDNSSolver) runScript(ctx context.Context, script, domain, fqdn, token, keyAuth string) error {
timeout := s.Timeout
if timeout == 0 {
timeout = 120 * time.Second
}
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(execCtx, script)
cmd.Env = append(cmd.Environ(),
"CERTCTL_DNS_DOMAIN="+domain,
"CERTCTL_DNS_FQDN="+fqdn,
"CERTCTL_DNS_VALUE="+keyAuth,
"CERTCTL_DNS_TOKEN="+token,
)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("DNS script %s failed: %w (output: %s)", script, err, string(output))
}
s.Logger.Debug("DNS script completed", "script", script, "output", string(output))
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package acme_test
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme"
)
func TestScriptDNSSolver(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("Present_Success", func(t *testing.T) {
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "dns-record.txt")
// Create a script that writes the DNS record to a file
scriptPath := filepath.Join(tmpDir, "present.sh")
script := `#!/bin/sh
echo "DOMAIN=$CERTCTL_DNS_DOMAIN FQDN=$CERTCTL_DNS_FQDN VALUE=$CERTCTL_DNS_VALUE TOKEN=$CERTCTL_DNS_TOKEN" > ` + outputFile + `
`
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatalf("Failed to create script: %v", err)
}
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
err := solver.Present(ctx, "example.com", "test-token", "test-key-auth")
if err != nil {
t.Fatalf("Present failed: %v", err)
}
// Verify the script was executed with correct env vars
output, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("Failed to read output file: %v", err)
}
expected := "DOMAIN=example.com FQDN=_acme-challenge.example.com VALUE=test-key-auth TOKEN=test-token\n"
if string(output) != expected {
t.Errorf("Script output mismatch:\ngot: %q\nwant: %q", string(output), expected)
}
})
t.Run("Present_ScriptFailure", func(t *testing.T) {
tmpDir := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fail.sh")
script := `#!/bin/sh
echo "error: something went wrong" >&2
exit 1
`
os.WriteFile(scriptPath, []byte(script), 0755)
solver := acmeissuer.NewScriptDNSSolver(scriptPath, "", logger)
err := solver.Present(ctx, "example.com", "token", "keyauth")
if err == nil {
t.Fatal("Expected error from failing script")
}
t.Logf("Correctly got error: %v", err)
})
t.Run("Present_NoScript", func(t *testing.T) {
solver := acmeissuer.NewScriptDNSSolver("", "", logger)
err := solver.Present(ctx, "example.com", "token", "keyauth")
if err == nil {
t.Fatal("Expected error when no script is configured")
}
})
t.Run("CleanUp_Success", func(t *testing.T) {
tmpDir := t.TempDir()
outputFile := filepath.Join(tmpDir, "cleanup.txt")
scriptPath := filepath.Join(tmpDir, "cleanup.sh")
script := `#!/bin/sh
echo "cleaned $CERTCTL_DNS_FQDN" > ` + outputFile + `
`
os.WriteFile(scriptPath, []byte(script), 0755)
solver := acmeissuer.NewScriptDNSSolver("", scriptPath, logger)
err := solver.CleanUp(ctx, "example.com", "token", "keyauth")
if err != nil {
t.Fatalf("CleanUp failed: %v", err)
}
output, _ := os.ReadFile(outputFile)
expected := "cleaned _acme-challenge.example.com\n"
if string(output) != expected {
t.Errorf("Cleanup output mismatch: got %q, want %q", string(output), expected)
}
})
t.Run("CleanUp_NoScript_Noop", func(t *testing.T) {
solver := acmeissuer.NewScriptDNSSolver("", "", logger)
// Should not error — cleanup without a script is a no-op
err := solver.CleanUp(ctx, "example.com", "token", "keyauth")
if err != nil {
t.Fatalf("CleanUp without script should not error: %v", err)
}
})
t.Run("Present_NonexistentScript", func(t *testing.T) {
solver := acmeissuer.NewScriptDNSSolver("/nonexistent/script.sh", "", logger)
err := solver.Present(ctx, "example.com", "token", "keyauth")
if err == nil {
t.Fatal("Expected error for nonexistent script")
}
})
}
+157 -19
View File
@@ -2,6 +2,9 @@ package local
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
@@ -12,6 +15,7 @@ import (
"fmt"
"log/slog"
"math/big"
"os"
"sync"
"time"
@@ -21,41 +25,57 @@ import (
// Config represents the local CA issuer connector configuration.
type Config struct {
// CACommonName is the CN for the self-signed CA certificate.
// Defaults to "CertCtl Local CA".
// Defaults to "CertCtl Local CA". Ignored in sub-CA mode.
CACommonName string `json:"ca_common_name,omitempty"`
// ValidityDays is the number of days a certificate is valid.
// Defaults to 90.
ValidityDays int `json:"validity_days,omitempty"`
// CACertPath is the path to a PEM-encoded CA certificate file.
// When set along with CAKeyPath, the connector operates in sub-CA mode:
// it loads the CA cert+key from disk instead of generating a self-signed root.
// The loaded CA cert should be signed by an upstream CA (e.g., ADCS).
// All issued certificates will chain to the upstream root.
CACertPath string `json:"ca_cert_path,omitempty"`
// CAKeyPath is the path to a PEM-encoded CA private key file (RSA or ECDSA).
// Required when CACertPath is set.
CAKeyPath string `json:"ca_key_path,omitempty"`
}
// Connector implements the issuer.Connector interface for local self-signed certificate generation.
// Connector implements the issuer.Connector interface for local certificate generation.
//
// This connector generates self-signed certificates using an in-memory CA. It is designed for
// development, testing, and demo purposes only and should NOT be used in production.
// It supports two modes:
//
// On first use, it generates a self-signed CA root certificate and stores it in memory.
// All issued certificates are signed by this local CA.
// Self-signed mode (default):
// - Generates an ephemeral self-signed CA root on first use
// - Designed for development, testing, and demo purposes
// - CA certificate is lost on service restart
//
// Sub-CA mode (when CACertPath + CAKeyPath are set):
// - Loads a pre-signed CA cert+key from disk
// - The CA cert should be signed by an upstream CA (e.g., ADCS, enterprise root)
// - All issued certificates chain to the upstream root
// - Suitable for production when the upstream CA is trusted
//
// Features:
// - Instant certificate issuance (no external CA required)
// - Full lifecycle demo support (issue, renew, revoke)
// - In-memory certificate storage
// - Full lifecycle support (issue, renew, revoke)
// - Proper X.509 certificate generation with SANs, serial numbers, and validity periods
//
// Limitations:
// - Not suitable for production use
// - Certificates are not trusted by default browsers/systems
// - No actual revocation checking (revocation is tracked in memory only)
// - CA certificate is ephemeral and lost on service restart
// - Revocation is tracked in memory only (not persistent)
// - In self-signed mode, CA is ephemeral
type Connector struct {
config *Config
logger *slog.Logger
mu sync.RWMutex
caKey *rsa.PrivateKey
caKey crypto.Signer // RSA or ECDSA private key
caCert *x509.Certificate
caCertPEM string
revokedMap map[string]bool // serial -> revoked status
subCA bool // true when loaded from disk (sub-CA mode)
revokedMap map[string]bool // serial -> revoked status
}
// New creates a new local CA connector with the given configuration and logger.
@@ -80,7 +100,6 @@ func New(config *Config, logger *slog.Logger) *Connector {
}
// ValidateConfig validates the local CA configuration.
// This always succeeds as the local CA has minimal requirements.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
@@ -91,12 +110,32 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
return fmt.Errorf("validity_days must be at least 1")
}
// Sub-CA mode: both paths must be set or neither
if (cfg.CACertPath != "") != (cfg.CAKeyPath != "") {
return fmt.Errorf("ca_cert_path and ca_key_path must both be set for sub-CA mode")
}
// Validate paths exist if set
if cfg.CACertPath != "" {
if _, err := os.Stat(cfg.CACertPath); err != nil {
return fmt.Errorf("ca_cert_path not accessible: %w", err)
}
if _, err := os.Stat(cfg.CAKeyPath); err != nil {
return fmt.Errorf("ca_key_path not accessible: %w", err)
}
}
c.config = &cfg
if c.config.CACommonName == "" {
c.config.CACommonName = "CertCtl Local CA"
}
mode := "self-signed"
if cfg.CACertPath != "" {
mode = "sub-CA"
}
c.logger.Info("local CA configuration validated",
"mode", mode,
"ca_common_name", c.config.CACommonName,
"validity_days", c.config.ValidityDays)
@@ -267,8 +306,8 @@ func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer
}
// ensureCA initializes the CA certificate and key if not already done.
// This is called on first IssueCertificate or RenewCertificate call.
// The CA is generated once and reused for all subsequent operations.
// In sub-CA mode (CACertPath + CAKeyPath set), loads from disk.
// Otherwise, generates an ephemeral self-signed CA.
func (c *Connector) ensureCA(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -277,7 +316,81 @@ func (c *Connector) ensureCA(ctx context.Context) error {
return nil // CA already initialized
}
c.logger.Info("initializing local CA", "common_name", c.config.CACommonName)
if c.config.CACertPath != "" && c.config.CAKeyPath != "" {
return c.loadCAFromDisk()
}
return c.generateSelfSignedCA()
}
// loadCAFromDisk loads a CA certificate and private key from PEM files on disk.
// This enables sub-CA mode where certctl operates as a subordinate CA under an
// enterprise root (e.g., ADCS). The loaded cert should have IsCA=true and
// KeyUsageCertSign set by the upstream CA.
func (c *Connector) loadCAFromDisk() error {
c.logger.Info("loading CA from disk (sub-CA mode)",
"cert_path", c.config.CACertPath,
"key_path", c.config.CAKeyPath)
// Load CA certificate
certPEM, err := os.ReadFile(c.config.CACertPath)
if err != nil {
return fmt.Errorf("failed to read CA certificate: %w", err)
}
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
return fmt.Errorf("invalid CA certificate PEM (expected CERTIFICATE block)")
}
caCert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse CA certificate: %w", err)
}
// Validate CA certificate properties
if !caCert.IsCA {
return fmt.Errorf("loaded certificate is not a CA (BasicConstraints.IsCA=false)")
}
if caCert.KeyUsage&x509.KeyUsageCertSign == 0 {
return fmt.Errorf("loaded CA certificate does not have KeyUsageCertSign")
}
// Load CA private key (supports RSA and ECDSA)
keyPEM, err := os.ReadFile(c.config.CAKeyPath)
if err != nil {
return fmt.Errorf("failed to read CA private key: %w", err)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return fmt.Errorf("invalid CA private key PEM")
}
caKey, err := parsePrivateKey(keyBlock)
if err != nil {
return fmt.Errorf("failed to parse CA private key: %w", err)
}
// Encode CA cert PEM for chain responses
c.caKey = caKey
c.caCert = caCert
c.caCertPEM = string(certPEM)
c.subCA = true
c.logger.Info("sub-CA initialized from disk",
"subject", caCert.Subject.CommonName,
"issuer", caCert.Issuer.CommonName,
"serial", caCert.SerialNumber,
"not_after", caCert.NotAfter,
"is_self_signed", caCert.Issuer.CommonName == caCert.Subject.CommonName)
return nil
}
// generateSelfSignedCA creates an ephemeral self-signed CA for development/demo.
func (c *Connector) generateSelfSignedCA() error {
c.logger.Info("generating self-signed CA (ephemeral mode)", "common_name", c.config.CACommonName)
// Generate CA private key
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
@@ -319,13 +432,36 @@ func (c *Connector) ensureCA(ctx context.Context) error {
c.caCert = caCert
c.caCertPEM = string(caCertPEM)
c.logger.Info("local CA initialized successfully",
c.logger.Info("self-signed CA initialized",
"serial", caCert.SerialNumber,
"not_after", caCert.NotAfter)
return nil
}
// parsePrivateKey parses a PEM block into an RSA or ECDSA private key.
func parsePrivateKey(block *pem.Block) (crypto.Signer, error) {
switch block.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(block.Bytes)
case "EC PRIVATE KEY":
return x509.ParseECPrivateKey(block.Bytes)
case "PRIVATE KEY":
// PKCS#8 — can contain RSA or ECDSA
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse PKCS#8 key: %w", err)
}
signer, ok := key.(crypto.Signer)
if !ok {
return nil, fmt.Errorf("PKCS#8 key is not a signing key")
}
return signer, nil
default:
return nil, fmt.Errorf("unsupported private key type: %s (expected RSA PRIVATE KEY, EC PRIVATE KEY, or PRIVATE KEY)", block.Type)
}
}
// generateCertificate creates an X.509 certificate signed by the local CA.
// It uses the CSR subject and adds any additional SANs from the request.
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string) (*x509.Certificate, string, string, error) {
@@ -441,6 +577,8 @@ func hashPublicKey(pub interface{}) []byte {
switch k := pub.(type) {
case *rsa.PublicKey:
h.Write(k.N.Bytes())
case *ecdsa.PublicKey:
h.Write(elliptic.Marshal(k.Curve, k.X, k.Y))
}
return h.Sum(nil)[:4] // Use first 4 bytes for brevity
}
@@ -2,6 +2,8 @@ package local_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
@@ -9,8 +11,11 @@ import (
"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/local"
@@ -171,6 +176,339 @@ func TestLocalConnector(t *testing.T) {
})
}
// Sub-CA mode tests
func TestSubCAMode(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("SubCA_RSA_IssueCertificate", func(t *testing.T) {
certPath, keyPath := generateTestSubCA(t, "rsa")
defer os.Remove(certPath)
defer os.Remove(keyPath)
config := &local.Config{
ValidityDays: 30,
CACertPath: certPath,
CAKeyPath: keyPath,
}
connector := local.New(config, logger)
_, csrPEM, err := generateTestCSR("app.internal.corp")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
req := issuer.IssuanceRequest{
CommonName: "app.internal.corp",
SANs: []string{"app.internal.corp"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("SubCA IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM is empty")
}
if result.ChainPEM == "" {
t.Error("ChainPEM is empty (should contain sub-CA cert)")
}
if result.Serial == "" {
t.Error("Serial is empty")
}
// Verify the issued cert is signed by the sub-CA (not self-signed)
certBlock, _ := pem.Decode([]byte(result.CertPEM))
if certBlock == nil {
t.Fatal("Failed to decode issued cert PEM")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
t.Fatalf("Failed to parse issued cert: %v", err)
}
// The issuer should be the sub-CA, not the cert itself
if cert.Issuer.CommonName == cert.Subject.CommonName {
t.Error("Issued cert appears to be self-signed (issuer == subject)")
}
t.Logf("Sub-CA issued cert: serial=%s, issuer=%s, subject=%s",
result.Serial, cert.Issuer.CommonName, cert.Subject.CommonName)
})
t.Run("SubCA_ECDSA_IssueCertificate", func(t *testing.T) {
certPath, keyPath := generateTestSubCA(t, "ecdsa")
defer os.Remove(certPath)
defer os.Remove(keyPath)
config := &local.Config{
ValidityDays: 30,
CACertPath: certPath,
CAKeyPath: keyPath,
}
connector := local.New(config, logger)
_, csrPEM, err := generateTestCSR("api.internal.corp")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
req := issuer.IssuanceRequest{
CommonName: "api.internal.corp",
SANs: []string{"api.internal.corp"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("SubCA ECDSA IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM is empty")
}
t.Logf("Sub-CA (ECDSA) issued cert: serial=%s", result.Serial)
})
t.Run("SubCA_ValidateConfig_MissingKeyPath", func(t *testing.T) {
cfg := local.Config{
ValidityDays: 30,
CACertPath: "/some/cert.pem",
// CAKeyPath intentionally omitted
}
connector := local.New(nil, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error when only CACertPath is set")
}
t.Logf("Correctly rejected partial sub-CA config: %v", err)
})
t.Run("SubCA_ValidateConfig_NonexistentPaths", func(t *testing.T) {
cfg := local.Config{
ValidityDays: 30,
CACertPath: "/nonexistent/ca.pem",
CAKeyPath: "/nonexistent/ca-key.pem",
}
connector := local.New(nil, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for nonexistent file paths")
}
t.Logf("Correctly rejected nonexistent paths: %v", err)
})
t.Run("SubCA_InvalidCertFile", func(t *testing.T) {
tmpDir := t.TempDir()
certPath := filepath.Join(tmpDir, "bad-cert.pem")
keyPath := filepath.Join(tmpDir, "bad-key.pem")
// Write garbage data
os.WriteFile(certPath, []byte("not a certificate"), 0600)
os.WriteFile(keyPath, []byte("not a key"), 0600)
config := &local.Config{
ValidityDays: 30,
CACertPath: certPath,
CAKeyPath: keyPath,
}
connector := local.New(config, logger)
_, csrPEM, _ := generateTestCSR("test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for invalid cert file")
}
t.Logf("Correctly rejected invalid cert file: %v", err)
})
t.Run("SubCA_NonCACert", func(t *testing.T) {
// Create a cert that is NOT a CA (no BasicConstraints.IsCA)
tmpDir := t.TempDir()
certPath, keyPath := generateTestNonCACert(t, tmpDir)
config := &local.Config{
ValidityDays: 30,
CACertPath: certPath,
CAKeyPath: keyPath,
}
connector := local.New(config, logger)
_, csrPEM, _ := generateTestCSR("test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for non-CA cert")
}
t.Logf("Correctly rejected non-CA cert: %v", err)
})
t.Run("SubCA_RenewCertificate", func(t *testing.T) {
certPath, keyPath := generateTestSubCA(t, "rsa")
defer os.Remove(certPath)
defer os.Remove(keyPath)
config := &local.Config{
ValidityDays: 30,
CACertPath: certPath,
CAKeyPath: keyPath,
}
connector := local.New(config, logger)
_, csrPEM, err := generateTestCSR("renew.internal.corp")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
renewReq := issuer.RenewalRequest{
CommonName: "renew.internal.corp",
SANs: []string{"renew.internal.corp"},
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("SubCA RenewCertificate failed: %v", err)
}
if result.Serial == "" {
t.Error("Serial is empty")
}
t.Logf("Sub-CA renewed cert: serial=%s", result.Serial)
})
}
// generateTestSubCA creates a self-signed CA cert+key pair and writes them to temp files.
// keyType can be "rsa" or "ecdsa".
func generateTestSubCA(t *testing.T, keyType string) (certPath, keyPath string) {
t.Helper()
tmpDir := t.TempDir()
certPath = filepath.Join(tmpDir, "ca.pem")
keyPath = filepath.Join(tmpDir, "ca-key.pem")
var privKey interface{}
var pubKey interface{}
var keyPEM []byte
switch keyType {
case "rsa":
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
privKey = rsaKey
pubKey = &rsaKey.PublicKey
keyPEM = pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(rsaKey),
})
case "ecdsa":
ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("Failed to generate ECDSA key: %v", err)
}
privKey = ecKey
pubKey = &ecKey.PublicKey
ecKeyBytes, err := x509.MarshalECPrivateKey(ecKey)
if err != nil {
t.Fatalf("Failed to marshal ECDSA key: %v", err)
}
keyPEM = pem.EncodeToMemory(&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: ecKeyBytes,
})
default:
t.Fatalf("Unsupported key type: %s", keyType)
}
// Create a CA certificate
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "Test Sub-CA",
Organization: []string{"CertCtl Test"},
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(5, 0, 0),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, pubKey, privKey)
if err != nil {
t.Fatalf("Failed to create CA cert: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
t.Fatalf("Failed to write CA cert: %v", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
t.Fatalf("Failed to write CA key: %v", err)
}
return certPath, keyPath
}
// generateTestNonCACert creates a cert+key pair where IsCA=false (not a CA cert).
func generateTestNonCACert(t *testing.T, tmpDir string) (certPath, keyPath string) {
t.Helper()
certPath = filepath.Join(tmpDir, "not-ca.pem")
keyPath = filepath.Join(tmpDir, "not-ca-key.pem")
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate RSA key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "Not A CA",
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: false, // NOT a CA
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &rsaKey.PublicKey, rsaKey)
if err != nil {
t.Fatalf("Failed to create non-CA cert: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey)})
os.WriteFile(certPath, certPEM, 0600)
os.WriteFile(keyPath, keyPEM, 0600)
return certPath, keyPath
}
func generateTestCSR(commonName string) (*x509.CertificateRequest, string, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
+461
View File
@@ -0,0 +1,461 @@
// 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=<CN>, iss=<provisioner>, aud=<ca-url>/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
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,367 @@
package stepca_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/stepca"
)
func TestStepCAConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("ValidateConfig_Success", func(t *testing.T) {
// Start a mock step-ca health endpoint
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := stepca.Config{
CAURL: srv.URL,
ProvisionerName: "test-provisioner",
ValidityDays: 90,
}
connector := stepca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_MissingCAURL", func(t *testing.T) {
config := stepca.Config{
ProvisionerName: "test",
}
connector := stepca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing ca_url")
}
})
t.Run("ValidateConfig_MissingProvisioner", func(t *testing.T) {
config := stepca.Config{
CAURL: "https://ca.example.com",
}
connector := stepca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing provisioner_name")
}
})
t.Run("ValidateConfig_UnreachableCA", func(t *testing.T) {
config := stepca.Config{
CAURL: "http://localhost:19999",
ProvisionerName: "test",
}
connector := stepca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for unreachable CA")
}
})
t.Run("IssueCertificate_Success", func(t *testing.T) {
// Generate a test certificate to return in the mock
testCertPEM, testKeyPEM := generateTestCert(t)
_ = testKeyPEM
// Start a mock step-ca server
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
case "/sign":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
resp := fmt.Sprintf(`{"crt": %q, "ca": %q}`, testCertPEM, testCertPEM)
w.Write([]byte(resp))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &stepca.Config{
CAURL: srv.URL,
ProvisionerName: "test-provisioner",
ValidityDays: 30,
}
connector := stepca.New(config, logger)
_, csrPEM, err := generateStepCATestCSR("app.internal.corp")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
req := issuer.IssuanceRequest{
CommonName: "app.internal.corp",
SANs: []string{"app.internal.corp"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM is empty")
}
if result.Serial == "" {
t.Error("Serial is empty")
}
if result.OrderID == "" {
t.Error("OrderID is empty")
}
t.Logf("step-ca issued cert: serial=%s", result.Serial)
})
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
case "/sign":
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid token"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &stepca.Config{
CAURL: srv.URL,
ProvisionerName: "test-provisioner",
}
connector := stepca.New(config, logger)
_, csrPEM, _ := generateStepCATestCSR("test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: csrPEM,
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for server error response")
}
t.Logf("Correctly got error: %v", err)
})
t.Run("RenewCertificate", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
case "/sign":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
resp := fmt.Sprintf(`{"crt": %q, "ca": %q}`, testCertPEM, testCertPEM)
w.Write([]byte(resp))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &stepca.Config{
CAURL: srv.URL,
ProvisionerName: "test-provisioner",
}
connector := stepca.New(config, logger)
_, csrPEM, _ := generateStepCATestCSR("renew.example.com")
renewReq := issuer.RenewalRequest{
CommonName: "renew.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.Run("RevokeCertificate_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
case "/revoke":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &stepca.Config{
CAURL: srv.URL,
ProvisionerName: "test-provisioner",
}
connector := stepca.New(config, logger)
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "1234567890",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
})
t.Run("RevokeCertificate_ServerError", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(http.StatusOK)
case "/revoke":
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"unauthorized"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
config := &stepca.Config{
CAURL: srv.URL,
ProvisionerName: "test-provisioner",
}
connector := stepca.New(config, logger)
revokeReq := issuer.RevocationRequest{
Serial: "1234567890",
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err == nil {
t.Fatal("Expected error for server error response")
}
})
t.Run("GetOrderStatus", func(t *testing.T) {
config := &stepca.Config{
CAURL: "https://ca.example.com",
ProvisionerName: "test-provisioner",
}
connector := stepca.New(config, logger)
status, err := connector.GetOrderStatus(ctx, "stepca-12345")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
})
}
// generateTestCert creates a self-signed test certificate and returns the PEM strings.
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "Test Certificate",
},
DNSNames: []string{"test.example.com"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
return certPEM, keyPEM
}
func generateStepCATestCSR(commonName string) (*x509.CertificateRequest, string, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, "", err
}
csrTemplate := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: []string{commonName},
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
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
}
+1
View File
@@ -67,6 +67,7 @@ type IssuerType string
const (
IssuerTypeACME IssuerType = "ACME"
IssuerTypeGenericCA IssuerType = "GenericCA"
IssuerTypeStepCA IssuerType = "StepCA"
)
// TargetType represents the type of deployment target.