mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
4e5522a999
F-001 (P3): GenerateDERCRL scoped to issuer via composite index
- Add RevocationRepository.ListByIssuer leveraging migration 000012's
idx_certificate_revocations_issuer_serial composite index as a
prefix-scan target. Previously CAOperationsSvc.GenerateDERCRL called
ListAll() and filtered by IssuerID in Go — O(total revocations)
regardless of how many revocations belonged to the target issuer.
- Rewrite GenerateDERCRL to call ListByIssuer(ctx, issuerID) so PostgreSQL
drives a prefix scan of the composite index. Drops the in-memory filter.
- New regression test in ca_operations_test.go asserts the CRL hot path
invokes ListByIssuer exactly once and never ListAll, and that the
issuerID is threaded through correctly.
F-002 (P3): digest.go admin-auth endpoints no longer leak internal errors
- PreviewDigest (GET /api/v1/digest/preview) and SendDigest
(POST /api/v1/digest/send) previously wrote err.Error() into the HTTP
response body on 500s. Replace with slog.Error server-side logging plus
a generic "internal error" response body, matching the house pattern
in certificates.go and export.go.
F-003 (P4): three blocking time.Sleep sites now honor ctx cancellation
- internal/connector/issuer/acme/acme.go:672 (DNS-01 propagation wait)
now runs under a select{case <-ctx.Done(): CleanUp + return ctx.Err();
case <-time.After(d):} so graceful shutdown doesn't get stuck behind
the propagation delay.
- internal/connector/issuer/acme/acme.go:786 (dns-persist-01 propagation
wait) same pattern, returns ctx.Err() on cancel.
- cmd/agent/main.go:272 (polling backoff inside the heartbeat loop) now
wraps the sleep in select{case <-ctx.Done(): continue; case <-time.After(backoff):}
so the outer <-ctx.Done() case on the parent loop fires cleanly.
Verification: build, vet, and race-enabled short tests green across all
55+ packages. govulncheck reports zero vulnerabilities in the code path.
No migration needed — F-001 reuses the existing 000012 composite index.
No frontend changes.
190 lines
6.5 KiB
Go
190 lines
6.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"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)
|
|
}
|
|
|
|
// Scope the query to this issuer so the migration 000012 composite index
|
|
// drives a prefix scan; previously this path read every revocation in the
|
|
// table and filtered in Go, which did not scale as the revocation table
|
|
// grew across many issuers (F-001).
|
|
revocations, err := s.revocationRepo.ListByIssuer(ctx, issuerID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list revocations for issuer %s: %w", issuerID, err)
|
|
}
|
|
|
|
// Convert revocations to CRL entries. Short-lived certificates (profile
|
|
// TTL < 1 hour) are excluded — expiry is sufficient revocation.
|
|
var entries []CRLEntry
|
|
for _, rev := range revocations {
|
|
// 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 && rev != nil {
|
|
// 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),
|
|
})
|
|
}
|
|
|
|
// Not revoked. Per RFC 6960 §2.2, we must only return "good" for a
|
|
// certificate that was actually issued by this CA. Verify the
|
|
// (issuer_id, serial) tuple maps to a real certificate in inventory
|
|
// before asserting "good"; otherwise return "unknown". This closes the
|
|
// coverage gap where forged/guessed serials would be accepted as valid
|
|
// because they had no revocation row (M-004).
|
|
if s.certRepo != nil {
|
|
cert, certErr := s.certRepo.GetByIssuerAndSerial(ctx, issuerID, serialHex)
|
|
if certErr != nil || cert == nil {
|
|
if certErr != nil && !errors.Is(certErr, sql.ErrNoRows) {
|
|
// Real repository failure — log but still fail closed with "unknown"
|
|
// rather than leaking a bogus "good" assertion.
|
|
slog.Warn("OCSP cert lookup failed; returning unknown",
|
|
"issuer_id", issuerID,
|
|
"serial", serialHex,
|
|
"error", certErr)
|
|
}
|
|
return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{
|
|
CertSerial: serial,
|
|
CertStatus: 2, // unknown
|
|
ThisUpdate: now,
|
|
NextUpdate: now.Add(1 * time.Hour),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Known cert, not revoked — return "good"
|
|
return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{
|
|
CertSerial: serial,
|
|
CertStatus: 0, // good
|
|
ThisUpdate: now,
|
|
NextUpdate: now.Add(1 * time.Hour),
|
|
})
|
|
}
|