Files
certctl/internal/service/ca_operations.go
T
shankar0123 cdc9d03d5b fix(m-2): thread context through CertificateService cluster
Collapses CertificateService, RevocationSvc, and CAOperationsSvc to
ctx-accepting method signatures. Removes context.Background() synthesis
at 24 internal call sites across certificate.go, revocation_svc.go, and
ca_operations.go.

- Primary repo calls inherit request cancellation via the passed ctx.
- Audit and notification dispatches use context.WithoutCancel(ctx) so
  they survive client disconnect.
- Collapses TriggerRenewal/TriggerRenewalWithActor,
  TriggerDeployment/TriggerDeploymentWithActor, and
  RevokeCertificate/RevokeCertificateWithActor sibling pairs into single
  canonical ctx-accepting methods (decisions D-1, D-2).

Handlers pass r.Context(). Mocks and tests updated to match new
signatures. No HTTP surface change, no OpenAPI change.

PR 1 of 6 in the M-2 remediation chain. Master green at this commit.

Refs: certctl-audit-report.md M-2 (L143, L224)
2026-04-18 00:29:37 +00:00

162 lines
5.1 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(ctx context.Context, 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(ctx)
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(ctx, rev.CertificateID)
if err == nil && cert.CertificateProfileID != "" {
profile, err := s.profileRepo.Get(ctx, 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(ctx, entries)
}
// GetOCSPResponse generates a signed OCSP response for the given certificate serial.
func (s *CAOperationsSvc) GetOCSPResponse(ctx context.Context, 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 (issuer_id, serial) — per RFC 5280 §5.2.3, serial numbers
// are unique only within a single issuer. The OCSP URL path carries issuer_id,
// so we scope the lookup to avoid cross-issuer collisions.
rev, _ := s.revocationRepo.GetByIssuerAndSerial(ctx, issuerID, serialHex)
if rev != nil {
cert, err := s.certRepo.Get(ctx, rev.CertificateID)
if err == nil && cert.CertificateProfileID != "" {
profile, err := s.profileRepo.Get(ctx, cert.CertificateProfileID)
if err == nil && profile.IsShortLived() {
return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{
CertSerial: serial,
CertStatus: 0, // good — short-lived exemption
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}
}
}
}
// Check if this (issuer_id, serial) is revoked — RFC 5280 §5.2.3 scoping.
rev, err := s.revocationRepo.GetByIssuerAndSerial(ctx, issuerID, serialHex)
if err != nil {
// Not revoked — return "good" status
return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{
CertSerial: serial,
CertStatus: 0, // good
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}
// Revoked
return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{
CertSerial: serial,
CertStatus: 1, // revoked
RevokedAt: rev.RevokedAt,
RevocationReason: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}