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
+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")
}
})
}