Files
certctl/internal/service/ca_operations.go
T
shankar0123 995b72df05 feat(M34): dynamic issuer configuration with encrypted config storage
Replace static env-var-based issuer wiring with GUI-driven dynamic
configuration stored encrypted in PostgreSQL. Operators can now
configure, test, enable/disable, and manage issuers from the dashboard
without restarting the server.

Key changes:
- AES-256-GCM encryption for sensitive issuer config at rest (PBKDF2
  key derivation with 100k iterations)
- Dynamic IssuerRegistry with sync.RWMutex replacing static map
- Connector factory pattern (issuerfactory.NewFromConfig) replacing
  140 lines of static wiring in main.go
- Migration 000009: encrypted_config, last_tested_at, test_status,
  source columns on issuers table
- Env var seeding on first boot with ON CONFLICT DO NOTHING
- Registry Rebuild() for atomic map swap after CRUD operations
- Issuer type validation against domain constants on Create
- Audit trail for test connection results
- Conditional seeding for step-ca/OpenSSL (only when env vars set)
- GUI: source badge, connection test status on issuer detail page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 00:20:13 -04:00

160 lines
5.0 KiB
Go

package service
import (
"context"
"fmt"
"log/slog"
"math/big"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// CAOperationsSvc provides CA operations: CRL generation and OCSP response signing.
// This service handles revocation status queries and certificate lifecycle operations
// related to the certificate authority.
type CAOperationsSvc struct {
revocationRepo repository.RevocationRepository
certRepo repository.CertificateRepository
profileRepo repository.CertificateProfileRepository
issuerRegistry *IssuerRegistry
}
// NewCAOperationsSvc creates a new CA operations service.
func NewCAOperationsSvc(
revocationRepo repository.RevocationRepository,
certRepo repository.CertificateRepository,
profileRepo repository.CertificateProfileRepository,
) *CAOperationsSvc {
return &CAOperationsSvc{
revocationRepo: revocationRepo,
certRepo: certRepo,
profileRepo: profileRepo,
}
}
// SetIssuerRegistry sets the issuer registry for CRL and OCSP operations.
func (s *CAOperationsSvc) SetIssuerRegistry(registry *IssuerRegistry) {
s.issuerRegistry = registry
}
// GenerateDERCRL generates a DER-encoded X.509 CRL for the given issuer.
// Short-lived certificates (profile TTL < 1 hour) are excluded from the CRL.
func (s *CAOperationsSvc) GenerateDERCRL(issuerID string) ([]byte, error) {
if s.revocationRepo == nil {
return nil, fmt.Errorf("revocation repository not configured")
}
if s.issuerRegistry == nil {
return nil, fmt.Errorf("issuer registry not configured")
}
issuerConn, ok := s.issuerRegistry.Get(issuerID)
if !ok {
return nil, fmt.Errorf("issuer not found: %s", issuerID)
}
revocations, err := s.revocationRepo.ListAll(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to list revocations: %w", err)
}
// Filter to this issuer and convert to CRL entries.
// Short-lived certificates (profile TTL < 1 hour) are excluded — expiry is sufficient revocation.
var entries []CRLEntry
for _, rev := range revocations {
if rev.IssuerID != issuerID {
continue
}
// Check short-lived exemption: look up the cert's profile
if s.profileRepo != nil && s.certRepo != nil {
cert, err := s.certRepo.Get(context.Background(), rev.CertificateID)
if err == nil && cert.CertificateProfileID != "" {
profile, err := s.profileRepo.Get(context.Background(), cert.CertificateProfileID)
if err == nil && profile.IsShortLived() {
slog.Debug("skipping short-lived cert from CRL",
"certificate_id", rev.CertificateID,
"profile_id", cert.CertificateProfileID)
continue
}
}
}
// Parse serial number from hex string
serial := new(big.Int)
serial.SetString(rev.SerialNumber, 16)
entries = append(entries, CRLEntry{
SerialNumber: serial,
RevokedAt: rev.RevokedAt,
ReasonCode: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
})
}
return issuerConn.GenerateCRL(context.Background(), entries)
}
// GetOCSPResponse generates a signed OCSP response for the given certificate serial.
func (s *CAOperationsSvc) GetOCSPResponse(issuerID string, serialHex string) ([]byte, error) {
if s.revocationRepo == nil {
return nil, fmt.Errorf("revocation repository not configured")
}
if s.issuerRegistry == nil {
return nil, fmt.Errorf("issuer registry not configured")
}
issuerConn, ok := s.issuerRegistry.Get(issuerID)
if !ok {
return nil, fmt.Errorf("issuer not found: %s", issuerID)
}
serial := new(big.Int)
serial.SetString(serialHex, 16)
now := time.Now()
// Short-lived cert exemption: if the cert's profile has TTL < 1 hour,
// always return "good" — expiry is sufficient revocation for short-lived certs.
if s.profileRepo != nil && s.certRepo != nil {
// Look up cert by serial through revocation table
rev, _ := s.revocationRepo.GetBySerial(context.Background(), serialHex)
if rev != nil {
cert, err := s.certRepo.Get(context.Background(), rev.CertificateID)
if err == nil && cert.CertificateProfileID != "" {
profile, err := s.profileRepo.Get(context.Background(), cert.CertificateProfileID)
if err == nil && profile.IsShortLived() {
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
CertSerial: serial,
CertStatus: 0, // good — short-lived exemption
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}
}
}
}
// Check if this serial is revoked
rev, err := s.revocationRepo.GetBySerial(context.Background(), serialHex)
if err != nil {
// Not revoked — return "good" status
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
CertSerial: serial,
CertStatus: 0, // good
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}
// Revoked
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
CertSerial: serial,
CertStatus: 1, // revoked
RevokedAt: rev.RevokedAt,
RevocationReason: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}