feat(M11c): crypto policy enforcement — CSR validation, MaxTTL caps, key metadata

Enforce certificate profile crypto constraints across all 5 issuance paths
(renewal, agent CSR, EST, SCEP). ValidateCSRAgainstProfile() rejects CSRs
with key algorithm/size that don't match profile rules. MaxTTL enforcement
caps certificate validity per issuer connector (Local CA, Vault, step-ca
enforce directly; ACME/DigiCert/Sectigo pass through). Key algorithm and
size are now persisted in certificate_versions for audit compliance.

16 new tests (12 service-layer + 4 Local CA connector). Removes hardcoded
version number from GUI sidebar. Documentation updated across architecture,
features, connectors, and README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-04-15 21:05:14 -04:00
parent f16a9c767a
commit f2e60b93a3
22 changed files with 779 additions and 70 deletions
+32 -2
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// ESTService implements the EST (RFC 7030) enrollment protocol.
@@ -20,6 +21,7 @@ type ESTService struct {
auditService *AuditService
logger *slog.Logger
profileID string // optional: constrain enrollments to a specific profile
profileRepo repository.CertificateProfileRepository
}
// NewESTService creates a new ESTService for the given issuer connector.
@@ -37,6 +39,11 @@ func (s *ESTService) SetProfileID(profileID string) {
s.profileID = profileID
}
// SetProfileRepo sets the profile repository for crypto policy enforcement during enrollment.
func (s *ESTService) SetProfileRepo(repo repository.CertificateProfileRepository) {
s.profileRepo = repo
}
// GetCACerts returns the PEM-encoded CA certificate chain for this EST server.
// RFC 7030 Section 4.1: /cacerts distributes the current CA certificates.
func (s *ESTService) GetCACerts(ctx context.Context) (string, error) {
@@ -109,15 +116,38 @@ func (s *ESTService) processEnrollment(ctx context.Context, csrPEM string, audit
sans = append(sans, uri.String())
}
// Validate CSR key algorithm/size against profile (crypto policy enforcement)
var profile *domain.CertificateProfile
var ekus []string
if s.profileID != "" && s.profileRepo != nil {
if p, profileErr := s.profileRepo.Get(ctx, s.profileID); profileErr == nil && p != nil {
profile = p
ekus = profile.AllowedEKUs
}
}
if _, csrErr := ValidateCSRAgainstProfile(csrPEM, profile); csrErr != nil {
s.logger.Error("EST enrollment rejected: crypto policy violation",
"action", auditAction,
"common_name", commonName,
"error", csrErr)
return nil, fmt.Errorf("EST enrollment rejected: %w", csrErr)
}
s.logger.Info("EST enrollment request",
"action", auditAction,
"common_name", commonName,
"sans", strings.Join(sans, ","),
"issuer", s.issuerID)
// Resolve MaxTTL from profile
var maxTTLSeconds int
if profile != nil {
maxTTLSeconds = profile.MaxTTLSeconds
}
// Issue the certificate via the configured issuer connector
// EST enrollments use default EKUs (nil = serverAuth + clientAuth fallback in connector)
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, nil)
// EST enrollments use profile EKUs if available, otherwise default (serverAuth + clientAuth fallback)
result, err := s.issuer.IssueCertificate(ctx, commonName, sans, csrPEM, ekus, maxTTLSeconds)
if err != nil {
s.logger.Error("EST enrollment failed",
"action", auditAction,