mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 14:38:53 +00:00
feat(ocsp): pre-signed response cache + invalidate-on-revoke (Phase 2)
Production hardening II Phase 2 — closes the per-request live-signing
bottleneck for OCSP. Mirrors the existing crl_cache pattern (migration
000019 / internal/service/crl_cache.go) but per (issuer_id, serial_hex)
instead of per-issuer.
LOAD-BEARING SECURITY INVARIANT: a revoked cert MUST NOT continue to
return the stale 'good' cached response after revocation. The
RevocationSvc.RevokeCertificateWithActor flow now calls
OCSPResponseCacheService.InvalidateOnRevoke after a successful revoke
so the next OCSP fetch falls through to live signing and returns the
revoked status. Pinned by TestOCSPCache_InvalidateOnRevoke_NextFetchReturnsRevoked.
NEW migrations/000024_ocsp_response_cache.{up,down}.sql with composite
PK (issuer_id, serial_hex), nullable revocation_reason / revoked_at,
next_update index for the scheduler refresh loop, issuer_id index for
admin observability.
NEW internal/domain/ocsp_response_cache.go::OCSPResponseCacheEntry +
IsStale helper.
NEW internal/repository/postgres/ocsp_response_cache.go implementing
repository.OCSPResponseCacheRepository (Get / Put / Delete /
CountByIssuer). Interface defined in internal/repository/interfaces.go.
NEW internal/service/ocsp_response_cache.go::OCSPResponseCacheService
with read-through facade + sync.Map singleflight + InvalidateOnRevoke.
On cache miss, calls caOperationsSvc.LiveSignOCSPResponse(nil) — the
NEW bypass-cache entry point — to break the cyclic dependency between
cache and CAOps.
REFACTORED internal/service/ca_operations.go:
- GetOCSPResponseWithNonce now dispatches: nil-nonce + cache wired
→ cacheSvc.Get (cache); nonce != nil OR cache nil → live-sign.
- LiveSignOCSPResponse is the new exported bypass-cache entry point;
contains the body of what was previously the GetOCSPResponse-
With-Nonce path.
- SetOCSPCacheSvc + new OCSPResponseCacher interface (cyclic-dep
break + test-injectable).
The cache stores nil-nonce blobs by design. Nonce-bearing requests
always live-sign because re-signing to add a nonce defeats caching;
this is a deliberate tradeoff — most relying parties don't send
nonces (Apple Push, Microsoft Edge SmartScreen, Firefox), and the
minority that do already accept the extra round-trip cost for replay
protection.
WIRED in cmd/server/main.go alongside the existing CRL cache wire:
ocspResponseCacheRepo + ocspResponseCacheService + SetOCSPCacheSvc +
SetOCSPCacheInvalidator. Existing deploys see no behavior change
(cache is consulted but on every cold-start the first fetch lands
through the live-sign + write-back path).
NOT YET WIRED in this commit (deferred to next phase commit to keep
this one shippable):
- Scheduler ocspCacheRefreshLoop (the warm-on-startup + N-hourly
refresh loop). The cache works without it; entries just live-sign
on miss + cache hit thereafter, so cold caches warm up
organically as relying parties query.
- Admin observability endpoint /api/v1/admin/ocsp/cache.
- CERTCTL_OCSP_CACHE_REFRESH_INTERVAL env var.
These three are the visible-but-not-load-bearing wires; the security
invariant (no stale-good-after-revoke) is fully shipped here.
7 new tests in internal/service/ocsp_response_cache_test.go pin every
documented invariant, with TestOCSPCache_InvalidateOnRevoke_NextFetch
ReturnsRevoked called out as the load-bearing security test.
Pre-commit verification: go build ./... clean; go test -short -count=1
green for service/ + handler/ + connector/issuer/local/.
This commit is contained in:
@@ -21,6 +21,29 @@ type CAOperationsSvc struct {
|
||||
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.
|
||||
@@ -105,14 +128,42 @@ func (s *CAOperationsSvc) GetOCSPResponse(ctx context.Context, issuerID string,
|
||||
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).
|
||||
// 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).
|
||||
//
|
||||
// Production hardening II Phase 1.
|
||||
// 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user