mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:11:31 +00:00
995b72df05
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>
160 lines
5.0 KiB
Go
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),
|
|
})
|
|
}
|