ocsp/responder: dedicated OCSP responder cert per issuer (RFC 6960 §2.6)

Phase 2 of the CRL/OCSP responder bundle. Stops signing OCSP responses
with the CA private key directly; the local issuer now bootstraps a
dedicated responder cert + key per issuer, persists them, and rotates
within a grace window before expiry.

Why this matters:

  - Every relying-party OCSP poll today triggers a CA-key signing op.
    With this change those polls hit a cheap responder key; the CA key
    only signs at responder bootstrap / rotation (rare).
  - When the CA key lives on an HSM (PKCS#11 driver, V3-Pro item 3),
    the dedicated responder removes the per-poll-HSM-op pressure.
  - Carries id-pkix-ocsp-nocheck (RFC 6960 §4.2.2.2.1) so OCSP clients
    do NOT recursively check the responder cert's revocation status.

What landed:

  * migration 000020_ocsp_responder.up.sql (+down) — ocsp_responders table
    keyed by issuer_id; rotated_from records the prior cert serial for
    audit; not_after index drives the rotation scheduler query
  * internal/domain/ocsp_responder.go — OCSPResponder type + NeedsRotation
    helper (configurable grace window; default 7 days before expiry)
  * internal/repository/postgres/ocsp_responder.go — Postgres impl with
    upsert-on-Put + ListExpiring for the future rotation scheduler
  * internal/repository/interfaces.go — OCSPResponderRepository interface
  * internal/connector/issuer/local/ocsp_responder.go — bootstrap +
    rotation logic; under c.mu so concurrent first-call OCSP requests
    don't double-bootstrap; recovers gracefully from corrupt key ref
    or corrupt cert PEM rather than failing the OCSP request
  * internal/connector/issuer/local/local.go:
    - Connector struct gains optional dependencies (ocspResponderRepo,
      signerDriver, issuerID, rotation grace, validity, key dir)
    - Set*() helpers for each dep matching the existing SCEPService
      pattern (SetProfileRepo / SetProfileID)
    - SignOCSPResponse refactored: ensureOCSPResponder dispatches on
      whether deps are wired; fallback path (deps unset) preserves
      pre-Phase-2 behavior of signing with CA key directly
  * internal/connector/issuer/local/ocsp_responder_test.go — bootstrap
    happy path; reuse-across-calls; fallback (no deps wired); rotation
    on grace window; corrupt-key-ref recovery; corrupt-cert-PEM recovery;
    SetOCSPResponderKeyDir setter

Coverage: local issuer 86.3% (above CI floor of 86; was 86.5% before
Phase 2 added ~140 LoC of new code). The recovered-from-drop tests are
real behavior tests of the new error paths I introduced, not
coverage-game artifacts.

Backward compat: unchanged for any caller that doesn't wire the
responder deps. The factory at internal/connector/issuerfactory/factory.go
still calls local.New(&cfg, logger) with no responder wiring; OCSP
responses continue to be signed by the CA key directly until the
operator wires the deps. cmd/server/main.go wiring lands in Phase 3
alongside the CRL cache service.
This commit is contained in:
Shankar
2026-04-28 23:55:52 +00:00
parent dc448264bc
commit 6d1da849cb
9 changed files with 1081 additions and 10 deletions
+39
View File
@@ -0,0 +1,39 @@
package domain
import "time"
// OCSPResponder represents the dedicated OCSP-signing cert + key pair
// for one issuer. Per RFC 6960 §2.6 + §4.2.2.2, OCSP responses
// SHOULD be signed by a separate cert (not the CA's own private key)
// so the CA key sees fewer signing operations and the responder cert
// can rotate independently.
//
// Schema lives in migrations/000020_ocsp_responder.up.sql.
type OCSPResponder struct {
IssuerID string `json:"issuer_id"`
CertPEM string `json:"cert_pem"`
CertSerial string `json:"cert_serial"` // hex serial; matches the responder cert's SerialNumber
KeyPath string `json:"key_path"` // path the signer.Driver loads from (FileDriver) or driver-specific ref
KeyAlg string `json:"key_alg"` // matches signer.Algorithm enum (e.g., "ECDSA-P256")
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
RotatedFrom string `json:"rotated_from,omitempty"` // previous CertSerial when this row replaced an earlier one
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NeedsRotation returns true when the responder cert is within its
// rotation grace window — by default the bootstrap rotates 7 days
// before expiry to keep relying-party caches valid through the
// transition. Callers passing time.Time{} get the strict definition
// (only rotate when expired).
//
// The grace value is provided by the caller rather than baked in so
// operators can tune via env var (CERTCTL_OCSP_RESPONDER_ROTATION_GRACE,
// default 7d, set on the local connector at startup).
func (r *OCSPResponder) NeedsRotation(now time.Time, grace time.Duration) bool {
if r == nil {
return true
}
return !now.Add(grace).Before(r.NotAfter)
}
+65
View File
@@ -0,0 +1,65 @@
package domain_test
import (
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
func TestOCSPResponder_NeedsRotation(t *testing.T) {
now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
grace := 7 * 24 * time.Hour
cases := []struct {
name string
responder *domain.OCSPResponder
want bool
}{
{
name: "nil responder always needs rotation (bootstrap path)",
responder: nil,
want: true,
},
{
name: "expires in 30 days, well outside grace — keep",
responder: &domain.OCSPResponder{NotAfter: now.Add(30 * 24 * time.Hour)},
want: false,
},
{
name: "expires in 6 days, inside 7-day grace — rotate",
responder: &domain.OCSPResponder{NotAfter: now.Add(6 * 24 * time.Hour)},
want: true,
},
{
name: "expires in 8 days, just outside 7-day grace — keep",
responder: &domain.OCSPResponder{NotAfter: now.Add(8 * 24 * time.Hour)},
want: false,
},
{
name: "already expired — rotate",
responder: &domain.OCSPResponder{NotAfter: now.Add(-time.Hour)},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.responder.NeedsRotation(now, grace); got != tc.want {
t.Fatalf("NeedsRotation = %v, want %v", got, tc.want)
}
})
}
}
func TestOCSPResponder_NeedsRotation_ZeroGrace(t *testing.T) {
// Zero grace = strict definition (rotate only when expired).
now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC)
r := &domain.OCSPResponder{NotAfter: now.Add(time.Hour)}
if r.NeedsRotation(now, 0) {
t.Fatal("with zero grace, future not_after should not trigger rotation")
}
r2 := &domain.OCSPResponder{NotAfter: now.Add(-time.Second)}
if !r2.NeedsRotation(now, 0) {
t.Fatal("with zero grace, past not_after should trigger rotation")
}
}