mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 23:38:53 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user