mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 09:58:52 +00:00
feat: M11a — certificate profiles, crypto policy enforcement, short-lived cert expiry
Add certificate profiles as named enrollment templates that control allowed key algorithms, max TTL, permitted EKUs, required SAN patterns, and optional SPIFFE URI SANs. CSR submissions are validated against profile rules at signing time (key type + minimum size). Short-lived certs (TTL < 1 hour) auto-expire via a new scheduler loop — expiry acts as revocation, no CRL/OCSP needed. New files: - Migration 000003: certificate_profiles table, FK columns on managed_certificates/renewal_policies, key metadata on certificate_versions - domain/profile.go: CertificateProfile + KeyAlgorithmRule structs - repository/postgres/profile.go: full CRUD with JSONB marshaling - service/profile.go: ProfileService with validation + audit logging - service/crypto_validation.go: CSR-against-profile validation (RSA/ECDSA/Ed25519) - handler/profiles.go: 5 HTTP endpoints under /api/v1/profiles - web/src/pages/ProfilesPage.tsx: profiles management page Modified: - renewal.go: CSR validation in CompleteAgentCSRRenewal, ExpireShortLivedCertificates - scheduler.go: 30s short-lived expiry check loop - certificate.go (repo): nullable profile FK, key metadata on versions - main.go: profile repo/service/handler wiring, 8-param NewRenewalService - router.go: 12-param RegisterHandlers with profile routes - seed_demo.sql: 4 demo profiles (standard, mtls, short-lived, high-security) - Frontend: types, API client, routing, sidebar nav Tests: 40 new tests across handler (15), service (13), crypto validation (12) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ type RenewalService struct {
|
||||
certRepo repository.CertificateRepository
|
||||
jobRepo repository.JobRepository
|
||||
renewalPolicyRepo repository.RenewalPolicyRepository
|
||||
profileRepo repository.CertificateProfileRepository
|
||||
auditService *AuditService
|
||||
notificationSvc *NotificationService
|
||||
issuerRegistry map[string]IssuerConnector
|
||||
@@ -52,6 +53,7 @@ func NewRenewalService(
|
||||
certRepo repository.CertificateRepository,
|
||||
jobRepo repository.JobRepository,
|
||||
renewalPolicyRepo repository.RenewalPolicyRepository,
|
||||
profileRepo repository.CertificateProfileRepository,
|
||||
auditService *AuditService,
|
||||
notificationSvc *NotificationService,
|
||||
issuerRegistry map[string]IssuerConnector,
|
||||
@@ -64,6 +66,7 @@ func NewRenewalService(
|
||||
certRepo: certRepo,
|
||||
jobRepo: jobRepo,
|
||||
renewalPolicyRepo: renewalPolicyRepo,
|
||||
profileRepo: profileRepo,
|
||||
auditService: auditService,
|
||||
notificationSvc: notificationSvc,
|
||||
issuerRegistry: issuerRegistry,
|
||||
@@ -371,6 +374,8 @@ func (s *RenewalService) processRenewalServerKeygen(ctx context.Context, job *do
|
||||
FingerprintSHA256: fingerprint,
|
||||
PEMChain: result.CertPEM + "\n" + result.ChainPEM,
|
||||
CSRPEM: privKeyPEM, // Server mode: stores private key for agent deployment
|
||||
KeyAlgorithm: domain.KeyAlgorithmRSA,
|
||||
KeySize: 2048,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -428,6 +433,22 @@ func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domai
|
||||
return fmt.Errorf("issuer connector not found for %s", cert.IssuerID)
|
||||
}
|
||||
|
||||
// Validate CSR against certificate profile (crypto policy enforcement)
|
||||
var profile *domain.CertificateProfile
|
||||
if cert.CertificateProfileID != "" && s.profileRepo != nil {
|
||||
var profileErr error
|
||||
profile, profileErr = s.profileRepo.Get(ctx, cert.CertificateProfileID)
|
||||
if profileErr != nil {
|
||||
slog.Warn("failed to fetch certificate profile, skipping crypto validation",
|
||||
"profile_id", cert.CertificateProfileID, "cert_id", cert.ID, "error", profileErr)
|
||||
}
|
||||
}
|
||||
csrInfo, csrErr := ValidateCSRAgainstProfile(csrPEM, profile)
|
||||
if csrErr != nil {
|
||||
s.failJob(ctx, job, fmt.Sprintf("CSR validation failed: %v", csrErr))
|
||||
return fmt.Errorf("CSR validation failed: %w", csrErr)
|
||||
}
|
||||
|
||||
// Update job to running
|
||||
if err := s.jobRepo.UpdateStatus(ctx, job.ID, domain.JobStatusRunning, ""); err != nil {
|
||||
return fmt.Errorf("failed to update job status: %w", err)
|
||||
@@ -462,6 +483,10 @@ func (s *RenewalService) CompleteAgentCSRRenewal(ctx context.Context, job *domai
|
||||
CSRPEM: csrPEM, // Agent mode: stores actual CSR, not private key
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if csrInfo != nil {
|
||||
version.KeyAlgorithm = csrInfo.KeyAlgorithm
|
||||
version.KeySize = csrInfo.KeySize
|
||||
}
|
||||
|
||||
if err := s.certRepo.CreateVersion(ctx, version); err != nil {
|
||||
s.failJob(ctx, job, fmt.Sprintf("version creation failed: %v", err))
|
||||
@@ -589,6 +614,73 @@ func (s *RenewalService) RetryFailedJobs(ctx context.Context, maxRetries int) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireShortLivedCertificates finds active certificates with short-lived profiles
|
||||
// whose TTL has elapsed and marks them as Expired. For certs with TTL < 1 hour,
|
||||
// expiry is the revocation mechanism — no CRL/OCSP needed.
|
||||
func (s *RenewalService) ExpireShortLivedCertificates(ctx context.Context) error {
|
||||
if s.profileRepo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get all Active certificates and check if any have expired based on their actual expiry time
|
||||
// This catches short-lived certs that expire between normal renewal check cycles
|
||||
now := time.Now()
|
||||
expiring, err := s.certRepo.GetExpiringCertificates(ctx, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch expired certificates: %w", err)
|
||||
}
|
||||
|
||||
for _, cert := range expiring {
|
||||
if cert.Status != domain.CertificateStatusActive && cert.Status != domain.CertificateStatusExpiring {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only auto-expire certs that have actually passed their expiry time
|
||||
if cert.ExpiresAt.After(now) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this cert has a short-lived profile
|
||||
if cert.CertificateProfileID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
profile, err := s.profileRepo.Get(ctx, cert.CertificateProfileID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to fetch profile for short-lived expiry check",
|
||||
"profile_id", cert.CertificateProfileID, "cert_id", cert.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !profile.IsShortLived() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as expired
|
||||
cert.Status = domain.CertificateStatusExpired
|
||||
cert.UpdatedAt = now
|
||||
if err := s.certRepo.Update(ctx, cert); err != nil {
|
||||
slog.Error("failed to expire short-lived cert", "cert_id", cert.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("short-lived certificate expired (expiry = revocation)",
|
||||
"cert_id", cert.ID, "profile_id", cert.CertificateProfileID,
|
||||
"expired_at", cert.ExpiresAt)
|
||||
|
||||
if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem,
|
||||
"short_lived_cert_expired", "certificate", cert.ID,
|
||||
map[string]interface{}{
|
||||
"profile_id": cert.CertificateProfileID,
|
||||
"expired_at": cert.ExpiresAt,
|
||||
}); auditErr != nil {
|
||||
slog.Error("failed to record audit event", "error", auditErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateID is a helper to generate unique IDs. In production, use a proper ID generator.
|
||||
func generateID(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
|
||||
Reference in New Issue
Block a user