mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 09:48:52 +00:00
3d15a3e5af
Production hardening II Phase 1.
The OCSP responder previously ignored the request's nonce extension
entirely, leaving relying parties vulnerable to replay attacks. RFC
6960 §4.4.1 defines the OPTIONAL id-pkix-ocsp-nonce extension (OID
1.3.6.1.5.5.7.48.1.2): when present in the request, the responder
MUST echo the same value in the response; when absent, no nonce in
the response (back-compat with relying parties that don't send one).
NEW internal/service/ocsp_nonce.go: ParseOCSPRequestNonce walks raw
DER (golang.org/x/crypto/ocsp.Request doesn't expose the request's
extensions field — the library only exposes IssuerNameHash +
IssuerKeyHash + SerialNumber). Returns one of three states:
- (nil, false, nil) — no nonce extension in request
- (nonce, true, nil) — well-formed nonce, ≤ MaxOCSPNonceLength (32)
- (nil, false, ErrOCSPNonceMalformed) — empty or oversized
NEW internal/service/ocsp_counters.go: sync/atomic counter table for
OCSP request lifecycle (request_get/post, request_success/invalid,
nonce_echoed, nonce_malformed, rate_limited, ...). Mirrors the EST/
SCEP counter pattern; Phase 8 wires these into /metrics/prometheus.
CertSrv types extended:
- internal/connector/issuer/interface.go::OCSPSignRequest gains
Nonce []byte field.
- internal/service/renewal.go::OCSPSignRequest (the service-layer
duplicate used by ca_operations.go) gains the same field.
- internal/service/issuer_adapter.go bridges the two.
Service path: CAOperationsSvc.GetOCSPResponseWithNonce(ctx, issuerID,
serialHex, nonce) is the new entry point that plumbs the nonce
through every signing site (good / revoked / unknown / short-lived).
The legacy GetOCSPResponse becomes a nil-nonce wrapper for back-
compat — every existing caller (tests, the GET handler) sees no
behavior change.
CertificateService gains the same WithNonce variant; the handler
interface adds it to the contract. MockCertificateService in tests
extended with the new method (delegates to the legacy fn when no
override is set, so existing tests that don't care about the nonce
keep working).
Local issuer's SignOCSPResponse appends the id-pkix-ocsp-nonce
extension (non-Critical per RFC 6960 §4.4) to the response template's
ExtraExtensions when req.Nonce != nil. The extnValue is the nonce
bytes wrapped in an OCTET STRING per RFC 6960 §4.4.1.
POST OCSP handler (HandleOCSPPost):
- After ocsp.ParseRequest succeeds, calls ParseOCSPRequestNonce on
the raw body to extract the optional nonce.
- On ErrOCSPNonceMalformed (empty or > 32 bytes): writes an
'unauthorized' OCSP response (status 6 per RFC 6960 §2.3) using
the canonical ocsp.UnauthorizedErrorResponse from x/crypto/ocsp.
Does NOT echo malicious bytes back.
- On well-formed nonce: passes it through GetOCSPResponseWithNonce.
- On no nonce: nil passed through; back-compat preserved.
GET OCSP handler unchanged — the GET form has no body to carry a
nonce extension.
6 new tests in internal/service/ocsp_nonce_test.go pin every
documented failure mode + the 32-byte boundary. The test fixture
builds an OCSPRequest via golang.org/x/crypto/ocsp.CreateRequest then
splices in a [2] EXPLICIT Extensions element by hand (the library
doesn't expose extension construction either).
Pre-commit verification: gofmt clean, go vet clean across affected
packages, go test -short -count=1 green for service/ + handler/ +
connector/issuer/local/. No new env vars introduced (Phase 1 is
always-on per RFC; no operator opt-out).
208 lines
7.3 KiB
Go
208 lines
7.3 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.
|
|
// 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 generates 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 (nonce extension). nil nonce
|
|
// omits the extension entirely (back-compat with relying parties that
|
|
// do not include one).
|
|
//
|
|
// Production hardening II Phase 1.
|
|
func (s *CAOperationsSvc) GetOCSPResponseWithNonce(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,
|
|
})
|
|
}
|