Complete V1 scaffold

This commit is contained in:
shankar0123
2026-03-14 20:01:53 -04:00
parent d395776a95
commit 3a9fe8ba37
30 changed files with 6131 additions and 104 deletions
+446
View File
@@ -0,0 +1,446 @@
package local
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"sync"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// 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".
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"`
}
// Connector implements the issuer.Connector interface for local self-signed 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.
//
// 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.
//
// Features:
// - Instant certificate issuance (no external CA required)
// - Full lifecycle demo support (issue, renew, revoke)
// - In-memory certificate storage
// - 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
type Connector struct {
config *Config
logger *slog.Logger
mu sync.RWMutex
caKey *rsa.PrivateKey
caCert *x509.Certificate
caCertPEM string
revokedMap map[string]bool // serial -> revoked status
}
// New creates a new local CA connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
if config == nil {
config = &Config{}
}
// Set defaults
if config.CACommonName == "" {
config.CACommonName = "CertCtl Local CA"
}
if config.ValidityDays == 0 {
config.ValidityDays = 90
}
return &Connector{
config: config,
logger: logger,
revokedMap: make(map[string]bool),
}
}
// 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 {
return fmt.Errorf("invalid local CA config: %w", err)
}
if cfg.ValidityDays < 1 {
return fmt.Errorf("validity_days must be at least 1")
}
c.config = &cfg
if c.config.CACommonName == "" {
c.config.CACommonName = "CertCtl Local CA"
}
c.logger.Info("local CA configuration validated",
"ca_common_name", c.config.CACommonName,
"validity_days", c.config.ValidityDays)
return nil
}
// IssueCertificate issues a new certificate signed by the local CA.
//
// The process:
// 1. Initialize the CA if not already done
// 2. Parse the CSR from the request
// 3. Extract subject and SANs from the CSR
// 4. Generate a random serial number
// 5. Create an X.509 certificate with proper extensions (SANs, key usage, etc.)
// 6. Sign with the local CA key
// 7. Return the certificate PEM and CA chain PEM
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing local CA issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Initialize CA if needed
if err := c.ensureCA(ctx); err != nil {
c.logger.Error("failed to initialize CA", "error", err)
return nil, fmt.Errorf("CA initialization failed: %w", err)
}
// Parse CSR
csrBlock, _ := pem.Decode([]byte(request.CSRPEM))
if csrBlock == nil || csrBlock.Type != "CERTIFICATE REQUEST" {
return nil, fmt.Errorf("invalid CSR PEM format")
}
csr, err := x509.ParseCertificateRequest(csrBlock.Bytes)
if err != nil {
c.logger.Error("failed to parse CSR", "error", err)
return nil, fmt.Errorf("invalid CSR: %w", err)
}
// Verify CSR signature
if err := csr.CheckSignature(); err != nil {
c.logger.Error("CSR signature verification failed", "error", err)
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
}
// Generate certificate
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
if err != nil {
c.logger.Error("failed to generate certificate", "error", err)
return nil, fmt.Errorf("certificate generation failed: %w", err)
}
// Create order ID (use serial as order ID for simplicity)
orderID := fmt.Sprintf("local-%s", serial)
result := &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: c.caCertPEM,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}
c.logger.Info("certificate issued successfully",
"serial", serial,
"common_name", request.CommonName,
"not_after", cert.NotAfter)
return result, nil
}
// RenewCertificate renews a certificate by issuing a new one with the same identifiers.
// For the local CA, this is functionally identical to IssueCertificate.
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing local CA renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Initialize CA if needed
if err := c.ensureCA(ctx); err != nil {
c.logger.Error("failed to initialize CA", "error", err)
return nil, fmt.Errorf("CA initialization failed: %w", err)
}
// Parse CSR
csrBlock, _ := pem.Decode([]byte(request.CSRPEM))
if csrBlock == nil || csrBlock.Type != "CERTIFICATE REQUEST" {
return nil, fmt.Errorf("invalid CSR PEM format")
}
csr, err := x509.ParseCertificateRequest(csrBlock.Bytes)
if err != nil {
c.logger.Error("failed to parse CSR", "error", err)
return nil, fmt.Errorf("invalid CSR: %w", err)
}
// Verify CSR signature
if err := csr.CheckSignature(); err != nil {
c.logger.Error("CSR signature verification failed", "error", err)
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
}
// Generate certificate
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs)
if err != nil {
c.logger.Error("failed to generate certificate", "error", err)
return nil, fmt.Errorf("certificate generation failed: %w", err)
}
// Create order ID
orderID := fmt.Sprintf("local-%s", serial)
if request.OrderID != nil {
orderID = *request.OrderID
}
result := &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: c.caCertPEM,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}
c.logger.Info("certificate renewed successfully",
"serial", serial,
"common_name", request.CommonName,
"not_after", cert.NotAfter)
return result, nil
}
// RevokeCertificate revokes a certificate by marking it in the in-memory revocation map.
// This is a no-op for practical purposes but tracks revocation state in memory.
// Note: Revocation is not persistent and is lost on service restart.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.mu.Lock()
defer c.mu.Unlock()
c.revokedMap[request.Serial] = true
reason := "unspecified"
if request.Reason != nil {
reason = *request.Reason
}
c.logger.Info("certificate revoked",
"serial", request.Serial,
"reason", reason)
return nil
}
// GetOrderStatus returns the status of an issuance or renewal order.
// For the local CA, orders complete immediately, so this always returns "completed" status.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Info("fetching local CA order status", "order_id", orderID)
// Local CA orders complete immediately
status := &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
UpdatedAt: time.Now(),
}
return status, nil
}
// 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.
func (c *Connector) ensureCA(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.caKey != nil {
return nil // CA already initialized
}
c.logger.Info("initializing local CA", "common_name", c.config.CACommonName)
// Generate CA private key
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate CA key: %w", err)
}
// Create CA certificate
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: c.config.CACommonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0), // CA valid for 10 years
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
// Self-sign the CA certificate
caCertBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
return fmt.Errorf("failed to create CA certificate: %w", err)
}
caCert, err := x509.ParseCertificate(caCertBytes)
if err != nil {
return fmt.Errorf("failed to parse CA certificate: %w", err)
}
// Encode CA certificate to PEM
caCertPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: caCertBytes,
})
c.caKey = caKey
c.caCert = caCert
c.caCertPEM = string(caCertPEM)
c.logger.Info("local CA initialized successfully",
"serial", caCert.SerialNumber,
"not_after", caCert.NotAfter)
return nil
}
// 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) {
// Generate random serial number
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
if err != nil {
return nil, "", "", fmt.Errorf("failed to generate serial number: %w", err)
}
serial := fmt.Sprintf("%040x", serialNum)
// Collect all SANs
sanSet := make(map[string]bool)
for _, san := range csr.DNSNames {
sanSet[san] = true
}
for _, san := range csr.IPAddresses {
sanSet[san.String()] = true
}
for _, san := range csr.EmailAddresses {
sanSet[san] = true
}
for _, san := range additionalSANs {
sanSet[san] = true
}
var dnsNames []string
var ips []string
var emails []string
for san := range sanSet {
// Try to parse as IP, otherwise treat as DNS or email
if ip := parseIP(san); ip != nil {
ips = append(ips, san)
} else if isEmail(san) {
emails = append(emails, san)
} else {
dnsNames = append(dnsNames, san)
}
}
// Create certificate template
now := time.Now()
template := &x509.Certificate{
SerialNumber: serialNum,
Subject: csr.Subject,
NotBefore: now,
NotAfter: now.AddDate(0, 0, c.config.ValidityDays),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
x509.ExtKeyUsageClientAuth,
},
DNSNames: dnsNames,
EmailAddresses: emails,
SubjectKeyId: hashPublicKey(csr.PublicKey),
AuthorityKeyId: c.caCert.SubjectKeyId,
}
// Add IP addresses if present
if len(ips) > 0 {
for _, ipStr := range ips {
if ip := parseIP(ipStr); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
}
}
}
// Sign certificate with CA
certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caKey)
if err != nil {
return nil, "", "", fmt.Errorf("failed to sign certificate: %w", err)
}
// Parse for validation
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, "", "", fmt.Errorf("failed to parse certificate: %w", err)
}
// Encode to PEM
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
return cert, string(certPEM), serial, nil
}
// parseIP attempts to parse a string as an IP address.
func parseIP(s string) []byte {
if s == "localhost" {
return []byte{127, 0, 0, 1}
}
// In production, use net.ParseIP for proper parsing.
// For now, return nil for non-localhost IPs.
return nil
}
// isEmail checks if a string looks like an email address.
func isEmail(s string) bool {
for _, c := range s {
if c == '@' {
return true
}
}
return false
}
// hashPublicKey generates a subject key identifier from a public key.
func hashPublicKey(pub interface{}) []byte {
h := sha256.New()
switch k := pub.(type) {
case *rsa.PublicKey:
h.Write(k.N.Bytes())
}
return h.Sum(nil)[:4] // Use first 4 bytes for brevity
}
@@ -0,0 +1,206 @@
package local_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"log/slog"
"os"
"testing"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/local"
)
func TestLocalConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Test 1: Create connector and validate config
t.Run("ValidateConfig", func(t *testing.T) {
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
// Test 2: Issue a certificate
t.Run("IssueCertificate", func(t *testing.T) {
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
csr, csrPEM, err := generateTestCSR("test.example.com")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
req := issuer.IssuanceRequest{
CommonName: csr.Subject.CommonName,
SANs: []string{"www.test.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.Serial == "" {
t.Error("Serial is empty")
}
if result.CertPEM == "" {
t.Error("CertPEM is empty")
}
if result.ChainPEM == "" {
t.Error("ChainPEM is empty")
}
if result.OrderID == "" {
t.Error("OrderID is empty")
}
if result.NotAfter.IsZero() {
t.Error("NotAfter is zero")
}
t.Logf("Certificate issued: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
// Test 3: Renew a certificate
t.Run("RenewCertificate", func(t *testing.T) {
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
csr, csrPEM, err := generateTestCSR("test.example.com")
if err != nil {
t.Fatalf("Failed to generate CSR: %v", err)
}
renewReq := issuer.RenewalRequest{
CommonName: csr.Subject.CommonName,
SANs: []string{"www.test.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.Serial == "" {
t.Error("Serial is empty")
}
t.Logf("Certificate renewed: serial=%s", result.Serial)
})
// Test 4: Get order status
t.Run("GetOrderStatus", func(t *testing.T) {
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
status, err := connector.GetOrderStatus(ctx, "local-12345")
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
t.Logf("Order status: %s", status.Status)
})
// Test 5: Revoke a certificate
t.Run("RevokeCertificate", func(t *testing.T) {
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
revokeReq := issuer.RevocationRequest{
Serial: "test-serial-12345",
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
t.Logf("Certificate revoked: serial=%s", revokeReq.Serial)
})
// Test 6: Invalid CSR
t.Run("InvalidCSR", func(t *testing.T) {
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: "invalid pem",
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for invalid CSR")
}
t.Logf("Correctly rejected invalid CSR: %v", err)
})
}
func generateTestCSR(commonName string) (*x509.CertificateRequest, string, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, "", err
}
subj := pkix.Name{
CommonName: commonName,
}
csrTemplate := x509.CertificateRequest{
Subject: subj,
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
}