mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 23:38:53 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user