mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:11:29 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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}.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ type IssuerType string
|
||||
const (
|
||||
IssuerTypeACME IssuerType = "ACME"
|
||||
IssuerTypeGenericCA IssuerType = "GenericCA"
|
||||
IssuerTypeStepCA IssuerType = "StepCA"
|
||||
)
|
||||
|
||||
// TargetType represents the type of deployment target.
|
||||
|
||||
Reference in New Issue
Block a user