Files
certctl/migrations/000020_ocsp_responder.up.sql
T
shankar0123 a0b7f7da9d 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.
2026-04-28 23:55:52 +00:00

45 lines
2.3 KiB
SQL

-- 000020_ocsp_responder.up.sql
--
-- Per-issuer OCSP responder cert + key tracking. Phase 2 of the
-- CRL/OCSP responder bundle.
--
-- WHY: RFC 6960 §2.6 + §4.2.2.2 strongly recommend that OCSP
-- responses be signed by a dedicated "OCSP responder cert" issued by
-- the CA, NOT by the CA's own private key. Signing OCSP with the CA
-- key directly means every relying-party OCSP fetch triggers a CA-key
-- signing operation — a problem when the CA key lives on an HSM
-- (every OCSP poll = HSM op = HSM-rate-limit risk + audit-volume
-- pressure) and a security smell otherwise (broader exposure surface
-- for the CA private key).
--
-- This table tracks one responder cert per issuer. The bootstrap
-- happens on first OCSP request (or at server startup if the row
-- doesn't exist) and rotates automatically when the responder cert
-- enters its 7-day-before-expiry window.
--
-- The responder cert MUST carry the id-pkix-ocsp-nocheck extension
-- (RFC 6960 §4.2.2.2.1) so OCSP clients don't recursively check the
-- responder cert's own revocation status.
--
-- Idempotent. Schema design: composite PK (issuer_id, cert_serial)
-- would let us track historical responder certs across rotations,
-- but operators don't need the history — only the current cert is
-- ever queried. PK on issuer_id alone, replace-on-rotate via UPSERT.
CREATE TABLE IF NOT EXISTS ocsp_responders (
issuer_id TEXT PRIMARY KEY REFERENCES issuers(id) ON DELETE CASCADE,
cert_pem TEXT NOT NULL, -- PEM-encoded responder cert
cert_serial TEXT NOT NULL, -- hex serial for ops grep / audit
key_path TEXT NOT NULL, -- filesystem path to the responder key (FileDriver) or driver-specific ref
key_alg TEXT NOT NULL, -- 'ECDSA-P256', 'RSA-2048', ... matches signer.Algorithm enum
not_before TIMESTAMPTZ NOT NULL,
not_after TIMESTAMPTZ NOT NULL,
rotated_from TEXT, -- previous cert_serial when rotation happens (NULL on first bootstrap)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Lets the rotation scheduler quickly find responders whose cert is
-- entering the 7-day-before-expiry window.
CREATE INDEX IF NOT EXISTS idx_ocsp_responders_not_after ON ocsp_responders(not_after);