mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:11:29 +00:00
a0b7f7da9d
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.
40 lines
1.7 KiB
Go
40 lines
1.7 KiB
Go
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)
|
|
}
|