Files
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

262 lines
9.8 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package service
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"math/big"
"time"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/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
// ocspCacheSvc — production hardening II Phase 2 read-through
// cache. When set, GetOCSPResponseWithNonce serves nil-nonce
// requests from the cache; nonce-bearing requests always go
// through the live signing path (the cached blob is signed with
// nil nonce, so a request that wants a nonce echo can't use it).
// Use SetOCSPCacheSvc to wire.
ocspCacheSvc OCSPResponseCacher
}
// OCSPResponseCacher is the minimum surface CAOperationsSvc consumes
// from the OCSP response cache. The cache service implements this
// interface; the indirection lets tests inject a fake cacher and
// avoids a service→service hard dep on the cache type.
type OCSPResponseCacher interface {
Get(ctx context.Context, issuerID, serialHex string) ([]byte, error)
InvalidateOnRevoke(ctx context.Context, issuerID, serialHex string) error
}
// SetOCSPCacheSvc wires the OCSP response cache. When set, nil-nonce
// requests through GetOCSPResponseWithNonce serve from the cache;
// nonce-bearing requests bypass.
func (s *CAOperationsSvc) SetOCSPCacheSvc(c OCSPResponseCacher) {
s.ocspCacheSvc = c
}
// 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.
// Back-compat wrapper around GetOCSPResponseWithNonce: passes nil nonce,
// which produces a response without the RFC 6960 §4.4.1 nonce extension.
// Older callers that don't carry a nonce see no behavior change.
func (s *CAOperationsSvc) GetOCSPResponse(ctx context.Context, issuerID string, serialHex string) ([]byte, error) {
return s.GetOCSPResponseWithNonce(ctx, issuerID, serialHex, nil)
}
// GetOCSPResponseWithNonce returns a signed OCSP response for the
// given certificate serial. When nonce is non-nil, the responder
// echoes it in the response per RFC 6960 §4.4.1; nil nonce omits the
// extension (back-compat).
//
// Dispatch: nil-nonce requests served from the OCSP response cache
// when wired (production hardening II Phase 2); nonce-bearing
// requests always live-sign because the cache stores nil-nonce blobs
// and re-signing to add the nonce defeats the point of caching.
//
// Production hardening II Phase 1 (nonce) + Phase 2 (cache dispatch).
func (s *CAOperationsSvc) GetOCSPResponseWithNonce(ctx context.Context, issuerID string, serialHex string, nonce []byte) ([]byte, error) {
if s.ocspCacheSvc != nil && len(nonce) == 0 {
// Cache wired and request has no nonce → read-through cache.
// On cache miss the cache service calls back into
// LiveSignOCSPResponse(nil) and writes the result back.
return s.ocspCacheSvc.Get(ctx, issuerID, serialHex)
}
return s.LiveSignOCSPResponse(ctx, issuerID, serialHex, nonce)
}
// LiveSignOCSPResponse is the unconditional signing path: it consults
// the revocation repo, decides good/revoked/unknown, and signs via
// the issuer connector. Bypasses the OCSP response cache.
//
// Used by:
// - GetOCSPResponseWithNonce when nonce != nil OR cache not wired.
// - OCSPResponseCacheService.Get on cache miss (the read-through
// fallback that produces the blob to write back to cache).
//
// Exported because the cache service needs to call it without
// re-entering the cache; ordinary handler callers should still go
// through GetOCSPResponseWithNonce.
//
// Production hardening II Phase 2.
func (s *CAOperationsSvc) LiveSignOCSPResponse(ctx context.Context, issuerID string, serialHex string, nonce []byte) ([]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),
Nonce: nonce,
})
}
}
}
}
// 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),
Nonce: nonce,
})
}
// 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),
Nonce: nonce,
})
}
}
// Known cert, not revoked — return "good"
return issuerConn.SignOCSPResponse(ctx, OCSPSignRequest{
CertSerial: serial,
CertStatus: 0, // good
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
Nonce: nonce,
})
}