mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +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:
@@ -69,6 +69,7 @@ import (
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/crypto/signer"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
"github.com/shankar0123/certctl/internal/validation"
|
||||
)
|
||||
|
||||
@@ -126,6 +127,27 @@ type Connector struct {
|
||||
caCertPEM string
|
||||
subCA bool // true when loaded from disk (sub-CA mode)
|
||||
revokedMap map[string]bool // serial -> revoked status
|
||||
|
||||
// Optional dependencies — set after construction via the
|
||||
// Set*-style helpers below. The Connector functions correctly with
|
||||
// any subset of these unset (the Phase-2 responder-cert path falls
|
||||
// back to direct CA-key signing for OCSP when not configured, and
|
||||
// the issuer ID falls back to the empty string for the
|
||||
// responder-row key).
|
||||
issuerID string
|
||||
ocspResponderRepo repository.OCSPResponderRepository
|
||||
signerDriver signer.Driver
|
||||
// ocspResponderRotationGrace is the window before NotAfter at
|
||||
// which the responder cert is rotated. Default 7 days; tunable
|
||||
// for tests + special operator deploys.
|
||||
ocspResponderRotationGrace time.Duration
|
||||
// ocspResponderValidity is how long a freshly-generated responder
|
||||
// cert is valid for. Default 30 days; tunable.
|
||||
ocspResponderValidity time.Duration
|
||||
// ocspResponderKeyDir is where FileDriver-backed responder keys
|
||||
// land. Empty = use the OS temp dir (fine for tests; production
|
||||
// callers should set this to a hardened path via the setter).
|
||||
ocspResponderKeyDir string
|
||||
}
|
||||
|
||||
// New creates a new local CA connector with the given configuration and logger.
|
||||
@@ -143,12 +165,81 @@ func New(config *Config, logger *slog.Logger) *Connector {
|
||||
}
|
||||
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
revokedMap: make(map[string]bool),
|
||||
config: config,
|
||||
logger: logger,
|
||||
revokedMap: make(map[string]bool),
|
||||
ocspResponderRotationGrace: 7 * 24 * time.Hour, // 7 days
|
||||
ocspResponderValidity: 30 * 24 * time.Hour, // 30 days
|
||||
}
|
||||
}
|
||||
|
||||
// SetOCSPResponderRepo wires the persistent store for the dedicated
|
||||
// OCSP-responder cert per RFC 6960 §2.6. When unset, SignOCSPResponse
|
||||
// falls back to signing with the CA key directly (the historical
|
||||
// behaviour, preserved for callers that don't supply this dep).
|
||||
//
|
||||
// Production wiring lives in cmd/server/main.go alongside the issuer
|
||||
// registry; tests inject a memory-backed repo via the same setter.
|
||||
func (c *Connector) SetOCSPResponderRepo(repo repository.OCSPResponderRepository) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.ocspResponderRepo = repo
|
||||
}
|
||||
|
||||
// SetSignerDriver wires the driver used to generate + load the OCSP
|
||||
// responder cert's private key. Required alongside SetOCSPResponderRepo
|
||||
// for the dedicated-responder path; without it the SignOCSPResponse
|
||||
// fallback (CA-key direct) takes over.
|
||||
func (c *Connector) SetSignerDriver(d signer.Driver) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.signerDriver = d
|
||||
}
|
||||
|
||||
// SetIssuerID records the issuer ID so the responder row can be keyed
|
||||
// off it. Without this the responder repo can't be consulted (an empty
|
||||
// issuer ID would collide across local-issuer instances). Falls through
|
||||
// to the fallback path when unset.
|
||||
func (c *Connector) SetIssuerID(id string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.issuerID = id
|
||||
}
|
||||
|
||||
// SetOCSPResponderRotationGrace overrides the default 7-day-before-expiry
|
||||
// rotation window for the dedicated responder cert. Tests use a small
|
||||
// value; operators with strict policies may set 14d or 30d.
|
||||
func (c *Connector) SetOCSPResponderRotationGrace(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if d > 0 {
|
||||
c.ocspResponderRotationGrace = d
|
||||
}
|
||||
}
|
||||
|
||||
// SetOCSPResponderValidity overrides the default 30-day validity for
|
||||
// freshly-generated responder certs. Operators preferring shorter
|
||||
// validity (with more frequent rotation) tune via this setter.
|
||||
func (c *Connector) SetOCSPResponderValidity(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if d > 0 {
|
||||
c.ocspResponderValidity = d
|
||||
}
|
||||
}
|
||||
|
||||
// SetOCSPResponderKeyDir sets the directory where FileDriver-backed
|
||||
// responder keys are written. Empty means "let the driver choose"
|
||||
// (typically the OS temp dir, fine for tests). Production callers MUST
|
||||
// set this to a hardened path; the FileDriver-installed
|
||||
// keystore.ensureKeyDirSecure equivalent applies the same 0700 +
|
||||
// permission gates as the CA key directory.
|
||||
func (c *Connector) SetOCSPResponderKeyDir(dir string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.ocspResponderKeyDir = dir
|
||||
}
|
||||
|
||||
// ValidateConfig validates the local CA configuration.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
@@ -878,18 +969,38 @@ func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.Revok
|
||||
}
|
||||
|
||||
// SignOCSPResponse signs an OCSP response for the given certificate.
|
||||
//
|
||||
// As of Phase 2 of the CRL/OCSP responder bundle, the signing path is
|
||||
// no longer hardwired to the CA private key. ensureOCSPResponder
|
||||
// returns the appropriate cert + signer based on whether the operator
|
||||
// has wired the dedicated-responder dependencies (SetOCSPResponderRepo
|
||||
// + SetSignerDriver + SetIssuerID):
|
||||
//
|
||||
// - Configured: the response is signed by a dedicated responder cert
|
||||
// (signed by the CA, has id-pkix-ocsp-nocheck per RFC 6960
|
||||
// §4.2.2.2.1). Relying parties see the responder cert in the
|
||||
// response's certificates field; CA-key signing operations stay
|
||||
// rare (only at responder bootstrap / rotation).
|
||||
//
|
||||
// - Unconfigured: falls back to signing with the CA key directly
|
||||
// (the historical pre-Phase-2 behaviour). Backward-compatible for
|
||||
// callers that don't wire the responder deps.
|
||||
//
|
||||
// The OCSP response template fields (status, serial, thisUpdate,
|
||||
// nextUpdate, revocation reason) are unchanged across both paths;
|
||||
// only the signing key + the cert in the response's certificates
|
||||
// field differ.
|
||||
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||
if err := c.ensureCA(ctx); err != nil {
|
||||
return nil, fmt.Errorf("CA initialization failed: %w", err)
|
||||
responderCert, responderSigner, err := c.ensureOCSPResponder(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure OCSP responder: %w", err)
|
||||
}
|
||||
|
||||
// Import OCSP after we confirm golang.org/x/crypto is available
|
||||
// This will be added to imports below
|
||||
template := ocsp.Response{
|
||||
SerialNumber: req.CertSerial,
|
||||
ThisUpdate: req.ThisUpdate,
|
||||
NextUpdate: req.NextUpdate,
|
||||
Certificate: c.caCert,
|
||||
Certificate: responderCert,
|
||||
}
|
||||
|
||||
switch req.CertStatus {
|
||||
@@ -903,14 +1014,22 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
|
||||
template.Status = ocsp.Unknown
|
||||
}
|
||||
|
||||
respBytes, err := ocsp.CreateResponse(c.caCert, c.caCert, template, c.caSigner)
|
||||
// ocsp.CreateResponse(issuer, responder, template, signer):
|
||||
// - issuer: always c.caCert (the CA that issued the cert
|
||||
// being checked, NOT the responder cert)
|
||||
// - responder: the responder cert (== c.caCert in the fallback
|
||||
// path; a dedicated responder cert otherwise)
|
||||
// - signer: the responder's signing key
|
||||
respBytes, err := ocsp.CreateResponse(c.caCert, responderCert, template, responderSigner)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OCSP response: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("OCSP response signed",
|
||||
"serial", req.CertSerial,
|
||||
"status", req.CertStatus)
|
||||
"status", req.CertStatus,
|
||||
"responder_cn", responderCert.Subject.CommonName,
|
||||
"dedicated_responder", responderCert != c.caCert)
|
||||
|
||||
return respBytes, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package local
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/crypto/signer"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// Bundle CRL/OCSP-Responder, Phase 2 — separate OCSP responder cert.
|
||||
//
|
||||
// Per RFC 6960 §2.6 + §4.2.2.2 the OCSP responder SHOULD be either the
|
||||
// CA itself OR a cert issued by the CA with the id-kp-OCSPSigning EKU.
|
||||
// The dedicated-responder shape is preferred because:
|
||||
//
|
||||
// 1. Every OCSP request signs ONE message — high-volume CAs see
|
||||
// thousands of OCSP polls per day. If those signs all use the
|
||||
// CA private key (the historical certctl behaviour), every
|
||||
// poll is a CA-key operation. With a separate responder cert,
|
||||
// the CA key signs only the responder cert (rarely — once per
|
||||
// ocspResponderValidity, default 30d) and OCSP polls hit the
|
||||
// responder key.
|
||||
// 2. When the CA key lives on an HSM (PKCS#11 driver, item 3 in
|
||||
// the V3-Pro roadmap), case (1) becomes a hard constraint —
|
||||
// every OCSP poll = HSM op = HSM-rate-limit pressure +
|
||||
// audit-volume blowup. The dedicated responder cert lives on
|
||||
// a cheaper (or even non-HSM) Signer driver.
|
||||
// 3. The id-pkix-ocsp-nocheck extension (RFC 6960 §4.2.2.2.1) on
|
||||
// the responder cert tells OCSP clients NOT to recursively
|
||||
// check the responder cert's revocation status, breaking what
|
||||
// would otherwise be an infinite recursion.
|
||||
//
|
||||
// This file implements the bootstrap + rotation. The responder cert
|
||||
// is issued by the local CA (signed with c.caSigner via
|
||||
// x509.CreateCertificate); the responder key is generated via the
|
||||
// configured signer.Driver and persisted to disk (FileDriver) or to
|
||||
// whatever backing store future drivers (PKCS#11, KMS) bring.
|
||||
//
|
||||
// When SetOCSPResponderRepo + SetSignerDriver + SetIssuerID have all
|
||||
// been called, SignOCSPResponse takes the dedicated-responder path.
|
||||
// Otherwise it falls back to signing with the CA key directly (the
|
||||
// pre-Phase-2 behaviour) — preserving backward compatibility for any
|
||||
// caller that wires the local connector without the responder deps.
|
||||
|
||||
// id-pkix-ocsp-nocheck OID per RFC 6960 §4.2.2.2.1. The extension
|
||||
// value is an ASN.1 NULL (DER bytes 0x05 0x00). When this extension is
|
||||
// present in a cert, OCSP clients MUST NOT check the cert's own
|
||||
// revocation status — preventing the infinite recursion that would
|
||||
// otherwise apply when the responder cert is itself signed by the CA
|
||||
// it validates.
|
||||
var oidOCSPNoCheck = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}
|
||||
var ocspNoCheckExtensionValue = []byte{0x05, 0x00} // DER: NULL
|
||||
|
||||
// ensureOCSPResponder returns the cert + signer to use for OCSP
|
||||
// response signing. The first return value is the responder cert (the
|
||||
// cert that will appear in the OCSP response's certificates field per
|
||||
// RFC 6960 §4.2.1); the second return value is the Signer used to
|
||||
// sign the response.
|
||||
//
|
||||
// Behavior:
|
||||
//
|
||||
// - If c.ocspResponderRepo + c.signerDriver + c.issuerID are not all
|
||||
// set, returns (c.caCert, c.caSigner, nil) — the historical
|
||||
// CA-key-direct path. Callers detect this case via responder ==
|
||||
// caCert and pass caCert as both `issuer` and `responder` to
|
||||
// ocsp.CreateResponse (which is the legal RFC 6960 form when the
|
||||
// responder IS the issuer).
|
||||
//
|
||||
// - Otherwise looks up the current responder via the repo. If
|
||||
// present and not in the rotation window, loads its key via the
|
||||
// signer driver and returns. If missing or in the rotation window,
|
||||
// bootstraps a fresh keypair + cert (signed by c.caSigner with
|
||||
// id-pkix-ocsp-nocheck), persists, returns the new pair.
|
||||
//
|
||||
// All bootstrap I/O happens under c.mu so concurrent first-call OCSP
|
||||
// requests don't double-bootstrap. The bootstrap is rare (once per
|
||||
// validity window per issuer) so the lock contention is negligible.
|
||||
func (c *Connector) ensureOCSPResponder(ctx context.Context) (*x509.Certificate, signer.Signer, error) {
|
||||
if err := c.ensureCA(ctx); err != nil {
|
||||
return nil, nil, fmt.Errorf("CA initialization failed: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Fallback: any required dep missing → use the CA key directly.
|
||||
// This preserves the pre-Phase-2 behaviour for callers that
|
||||
// haven't wired the responder repo / signer driver / issuer ID.
|
||||
if c.ocspResponderRepo == nil || c.signerDriver == nil || c.issuerID == "" {
|
||||
return c.caCert, c.caSigner, nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Lookup current responder.
|
||||
current, err := c.ocspResponderRepo.Get(ctx, c.issuerID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("ocsp responder repo Get %q: %w", c.issuerID, err)
|
||||
}
|
||||
|
||||
if current != nil && !current.NeedsRotation(now, c.ocspResponderRotationGrace) {
|
||||
// Existing responder is good — load its key and return.
|
||||
responderSigner, err := c.signerDriver.Load(ctx, current.KeyPath)
|
||||
if err != nil {
|
||||
// Key file missing or corrupt → treat as needs-bootstrap
|
||||
// rather than failing. This recovers from operator
|
||||
// mistakes (deleting the key file) without requiring
|
||||
// manual intervention.
|
||||
c.logger.Warn("OCSP responder key load failed; bootstrapping fresh responder",
|
||||
"issuer_id", c.issuerID, "key_path", current.KeyPath, "error", err)
|
||||
} else {
|
||||
cert, err := parseSinglePEMCert([]byte(current.CertPEM))
|
||||
if err == nil {
|
||||
return cert, responderSigner, nil
|
||||
}
|
||||
c.logger.Warn("OCSP responder cert parse failed; bootstrapping fresh responder",
|
||||
"issuer_id", c.issuerID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap path: generate fresh key + sign new responder cert.
|
||||
cert, sig, err := c.bootstrapOCSPResponder(ctx, current, now)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("ocsp responder bootstrap: %w", err)
|
||||
}
|
||||
return cert, sig, nil
|
||||
}
|
||||
|
||||
// bootstrapOCSPResponder generates a new ECDSA P-256 key via the
|
||||
// configured signer driver, signs an OCSP-Signing-EKU + OCSP-no-check
|
||||
// cert with c.caSigner, persists, and returns the cert + signer.
|
||||
//
|
||||
// Caller MUST hold c.mu. previous is the prior responder row (may be
|
||||
// nil); when non-nil its CertSerial is recorded in rotated_from for
|
||||
// audit.
|
||||
func (c *Connector) bootstrapOCSPResponder(ctx context.Context, previous *domain.OCSPResponder, now time.Time) (*x509.Certificate, signer.Signer, error) {
|
||||
// 1. Generate the responder keypair. ECDSA P-256 is the default;
|
||||
// operators wanting a different alg can extend the driver
|
||||
// contract later (today the bootstrap hardcodes the alg to
|
||||
// keep the surface small).
|
||||
const responderAlg = signer.AlgorithmECDSAP256
|
||||
|
||||
keyDir := c.ocspResponderKeyDir
|
||||
if keyDir == "" {
|
||||
keyDir = "." // fall back to cwd; tests use t.TempDir() via SetOCSPResponderKeyDir
|
||||
}
|
||||
|
||||
// FileDriver-shaped contract: the driver picks the path via its
|
||||
// GenerateOutPath hook. For the FileDriver we configure here, we
|
||||
// inject a hook that produces <keyDir>/ocsp-responder-<issuerID>.key
|
||||
// — a stable name so rotation overwrites in place.
|
||||
keyName := fmt.Sprintf("ocsp-responder-%s.key", c.issuerID)
|
||||
keyPath := filepath.Join(keyDir, keyName)
|
||||
|
||||
// Configure the FileDriver's hooks if the supplied driver is one.
|
||||
// Other drivers (MemoryDriver in tests, future PKCS#11) bring
|
||||
// their own ref-naming policy and we just use whatever ref they
|
||||
// return.
|
||||
if fd, ok := c.signerDriver.(*signer.FileDriver); ok {
|
||||
// Inject the destination path. DirHardener stays whatever the
|
||||
// caller installed (typically keystore.ensureKeyDirSecure
|
||||
// adapter from cmd/server/main.go).
|
||||
if fd.GenerateOutPath == nil {
|
||||
fd.GenerateOutPath = func(_ signer.Algorithm) (string, error) {
|
||||
return keyPath, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
responderSigner, generatedRef, err := c.signerDriver.Generate(ctx, responderAlg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate responder key: %w", err)
|
||||
}
|
||||
if generatedRef != "" {
|
||||
keyPath = generatedRef
|
||||
}
|
||||
|
||||
// 2. Build the responder cert template per RFC 6960 §4.2.2.2:
|
||||
// KeyUsage: digitalSignature
|
||||
// ExtKeyUsage: id-kp-OCSPSigning
|
||||
// Extensions: id-pkix-ocsp-nocheck (NULL)
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate responder serial: %w", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: fmt.Sprintf("OCSP Responder for %s", c.caCert.Subject.CommonName),
|
||||
},
|
||||
NotBefore: now.Add(-5 * time.Minute), // small backdate to absorb clock skew between certctl and relying parties
|
||||
NotAfter: now.Add(c.ocspResponderValidity),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageOCSPSigning,
|
||||
},
|
||||
ExtraExtensions: []pkix.Extension{
|
||||
{
|
||||
Id: oidOCSPNoCheck,
|
||||
Critical: false,
|
||||
Value: ocspNoCheckExtensionValue,
|
||||
},
|
||||
},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: false,
|
||||
}
|
||||
|
||||
// 3. Sign with the CA key (c.caSigner from the Signer interface).
|
||||
// Public key for the cert is the responder's own public key.
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, responderSigner.Public(), c.caSigner)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("sign responder cert: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(derBytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse signed responder cert: %w", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
|
||||
// 4. Persist.
|
||||
row := &domain.OCSPResponder{
|
||||
IssuerID: c.issuerID,
|
||||
CertPEM: string(pemBytes),
|
||||
CertSerial: fmt.Sprintf("%x", serial),
|
||||
KeyPath: keyPath,
|
||||
KeyAlg: string(responderAlg),
|
||||
NotBefore: template.NotBefore,
|
||||
NotAfter: template.NotAfter,
|
||||
}
|
||||
if previous != nil {
|
||||
row.RotatedFrom = previous.CertSerial
|
||||
}
|
||||
if err := c.ocspResponderRepo.Put(ctx, row); err != nil {
|
||||
return nil, nil, fmt.Errorf("persist responder row: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("OCSP responder bootstrapped",
|
||||
"issuer_id", c.issuerID,
|
||||
"cert_serial", row.CertSerial,
|
||||
"not_after", row.NotAfter,
|
||||
"rotated_from", row.RotatedFrom)
|
||||
|
||||
return cert, responderSigner, nil
|
||||
}
|
||||
|
||||
// parseSinglePEMCert decodes the first PEM block in pemBytes as an
|
||||
// X.509 certificate. Used by ensureOCSPResponder to materialize a
|
||||
// cert from the persisted CertPEM string.
|
||||
func parseSinglePEMCert(pemBytes []byte) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("expected CERTIFICATE block, got %q", block.Type)
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package local_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ocsp"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer/local"
|
||||
"github.com/shankar0123/certctl/internal/crypto/signer"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// fakeResponderRepo is an in-memory repository.OCSPResponderRepository
|
||||
// for tests that exercise the responder bootstrap path without needing
|
||||
// a real Postgres + testcontainers harness. The Postgres impl is
|
||||
// covered by the testcontainers tests in
|
||||
// internal/repository/postgres/ocsp_responder_test.go (CI only — needs
|
||||
// Docker).
|
||||
type fakeResponderRepo struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]*domain.OCSPResponder
|
||||
putCount int // bumped on every Put for assertion
|
||||
getCount int
|
||||
}
|
||||
|
||||
func newFakeResponderRepo() *fakeResponderRepo {
|
||||
return &fakeResponderRepo{rows: map[string]*domain.OCSPResponder{}}
|
||||
}
|
||||
|
||||
func (r *fakeResponderRepo) Get(ctx context.Context, issuerID string) (*domain.OCSPResponder, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.getCount++
|
||||
if row, ok := r.rows[issuerID]; ok {
|
||||
// Return a copy so callers can't mutate our state.
|
||||
copy := *row
|
||||
return ©, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeResponderRepo) Put(ctx context.Context, responder *domain.OCSPResponder) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.putCount++
|
||||
copy := *responder
|
||||
r.rows[responder.IssuerID] = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeResponderRepo) ListExpiring(ctx context.Context, grace time.Duration, now time.Time) ([]*domain.OCSPResponder, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
var out []*domain.OCSPResponder
|
||||
threshold := now.Add(grace)
|
||||
for _, row := range r.rows {
|
||||
if !row.NotAfter.After(threshold) {
|
||||
copy := *row
|
||||
out = append(out, ©)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// helper: build a Connector wired for the responder bootstrap path.
|
||||
func newConnectorWithResponderDeps(t *testing.T) (*local.Connector, *fakeResponderRepo) {
|
||||
t.Helper()
|
||||
|
||||
conn := local.New(&local.Config{
|
||||
CACommonName: "Test Local CA",
|
||||
ValidityDays: 30,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
repo := newFakeResponderRepo()
|
||||
driver := signer.NewMemoryDriver()
|
||||
|
||||
conn.SetOCSPResponderRepo(repo)
|
||||
conn.SetSignerDriver(driver)
|
||||
conn.SetIssuerID("iss-test-local")
|
||||
|
||||
return conn, repo
|
||||
}
|
||||
|
||||
// helper: forge an OCSP request for a given serial. The local connector's
|
||||
// SignOCSPResponse takes a typed request struct, not raw OCSP bytes.
|
||||
func ocspReqFor(serial *big.Int, status int) issuer.OCSPSignRequest {
|
||||
now := time.Now().UTC()
|
||||
return issuer.OCSPSignRequest{
|
||||
CertSerial: serial,
|
||||
CertStatus: status,
|
||||
ThisUpdate: now,
|
||||
NextUpdate: now.Add(24 * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase-2 bootstrap path coverage.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSignOCSPResponse_DedicatedResponder_Bootstrapped(t *testing.T) {
|
||||
conn, repo := newConnectorWithResponderDeps(t)
|
||||
ctx := context.Background()
|
||||
|
||||
respBytes, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(0xDEAD), 0))
|
||||
if err != nil {
|
||||
t.Fatalf("SignOCSPResponse: %v", err)
|
||||
}
|
||||
if len(respBytes) == 0 {
|
||||
t.Fatal("OCSP response is empty")
|
||||
}
|
||||
|
||||
// Verify the responder row was persisted.
|
||||
if repo.putCount != 1 {
|
||||
t.Errorf("expected exactly 1 Put on first call, got %d", repo.putCount)
|
||||
}
|
||||
row, _ := repo.Get(ctx, "iss-test-local")
|
||||
if row == nil {
|
||||
t.Fatal("responder row was not persisted")
|
||||
}
|
||||
if row.KeyAlg != "ECDSA-P256" {
|
||||
t.Errorf("KeyAlg = %q, want ECDSA-P256 (the bootstrap default)", row.KeyAlg)
|
||||
}
|
||||
if row.NotAfter.Sub(row.NotBefore) < 24*time.Hour {
|
||||
t.Errorf("validity window too short: %v", row.NotAfter.Sub(row.NotBefore))
|
||||
}
|
||||
|
||||
// Parse the responder cert and check the OCSP-specific properties.
|
||||
block, _ := pem.Decode([]byte(row.CertPEM))
|
||||
if block == nil {
|
||||
t.Fatal("responder CertPEM is not PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse responder cert: %v", err)
|
||||
}
|
||||
|
||||
// EKU must include OCSPSigning per RFC 6960 §4.2.2.2.
|
||||
hasOCSPSigning := false
|
||||
for _, eku := range cert.ExtKeyUsage {
|
||||
if eku == x509.ExtKeyUsageOCSPSigning {
|
||||
hasOCSPSigning = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOCSPSigning {
|
||||
t.Error("responder cert missing ExtKeyUsageOCSPSigning")
|
||||
}
|
||||
|
||||
// id-pkix-ocsp-nocheck (RFC 6960 §4.2.2.2.1) — verify the extension OID
|
||||
// shows up in the cert's Extensions list. The Go stdlib does not
|
||||
// promote this extension into a typed field; check ExtraExtensions
|
||||
// equivalent via the raw Extensions slice.
|
||||
noCheckOID := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}
|
||||
hasNoCheck := false
|
||||
for _, ext := range cert.Extensions {
|
||||
if ext.Id.Equal(noCheckOID) {
|
||||
hasNoCheck = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNoCheck {
|
||||
t.Error("responder cert missing id-pkix-ocsp-nocheck extension")
|
||||
}
|
||||
|
||||
// The OCSP response should be signed by the responder cert, not by
|
||||
// the CA cert. Parse the response with the issuer cert as the trust
|
||||
// anchor — ocsp.ParseResponse reads the certificates field from the
|
||||
// response itself and verifies the chain back to issuer.
|
||||
caPEM, err := conn.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCACertPEM: %v", err)
|
||||
}
|
||||
caBlock, _ := pem.Decode([]byte(caPEM))
|
||||
caCert, err := x509.ParseCertificate(caBlock.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CA cert: %v", err)
|
||||
}
|
||||
|
||||
parsedResp, err := ocsp.ParseResponse(respBytes, caCert)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseResponse with CA as issuer: %v", err)
|
||||
}
|
||||
if parsedResp.SerialNumber.Cmp(big.NewInt(0xDEAD)) != 0 {
|
||||
t.Errorf("response serial mismatch: got %v want %v", parsedResp.SerialNumber, 0xDEAD)
|
||||
}
|
||||
if parsedResp.Status != ocsp.Good {
|
||||
t.Errorf("response status = %d, want Good (0)", parsedResp.Status)
|
||||
}
|
||||
// The response's Certificate field should be the responder cert
|
||||
// (NOT the CA cert) — that's the proof the dedicated-responder
|
||||
// path was taken.
|
||||
if parsedResp.Certificate == nil {
|
||||
t.Fatal("OCSP response did not include the responder cert")
|
||||
}
|
||||
if parsedResp.Certificate.Subject.CommonName == caCert.Subject.CommonName {
|
||||
t.Errorf("OCSP response was signed by the CA, not by a dedicated responder cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_DedicatedResponder_ReusedAcrossCalls(t *testing.T) {
|
||||
conn, repo := newConnectorWithResponderDeps(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(int64(i+1)), 0))
|
||||
if err != nil {
|
||||
t.Fatalf("SignOCSPResponse[%d]: %v", i, err)
|
||||
}
|
||||
}
|
||||
// Bootstrap on first call only — subsequent calls should reuse the
|
||||
// persisted responder. putCount > 1 means we re-bootstrapped (bug).
|
||||
if repo.putCount != 1 {
|
||||
t.Errorf("putCount = %d, want 1 (responder should be reused across calls)", repo.putCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_FallbackPath_NoResponderDeps(t *testing.T) {
|
||||
// Construct a connector WITHOUT responder deps wired. SignOCSPResponse
|
||||
// must fall back to the historical CA-key-direct path and not error.
|
||||
conn := local.New(&local.Config{ValidityDays: 30}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
ctx := context.Background()
|
||||
|
||||
respBytes, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(0xCAFE), 0))
|
||||
if err != nil {
|
||||
t.Fatalf("fallback SignOCSPResponse: %v", err)
|
||||
}
|
||||
if len(respBytes) == 0 {
|
||||
t.Fatal("fallback OCSP response is empty")
|
||||
}
|
||||
// The fallback path uses the CA cert as the responder — the response
|
||||
// bytes parse against the CA cert successfully.
|
||||
caPEM, err := conn.GetCACertPEM(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCACertPEM: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode([]byte(caPEM))
|
||||
caCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse CA cert: %v", err)
|
||||
}
|
||||
if _, err := ocsp.ParseResponse(respBytes, caCert); err != nil {
|
||||
t.Fatalf("fallback OCSP response should validate against CA cert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_DedicatedResponder_RecoversFromCorruptKeyRef(t *testing.T) {
|
||||
// Simulate the failure mode where the persisted responder row points
|
||||
// at a key the signer driver can't load (e.g., operator deleted the
|
||||
// key file out from under us). The bootstrap path should recover by
|
||||
// generating a fresh responder rather than failing the OCSP request.
|
||||
conn, repo := newConnectorWithResponderDeps(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Pre-populate the repo with a stale row whose KeyPath the
|
||||
// MemoryDriver doesn't know about. MemoryDriver.Load returns an
|
||||
// "unknown ref" error for any ref it didn't issue.
|
||||
stale := &domain.OCSPResponder{
|
||||
IssuerID: "iss-test-local",
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\nbm90LWEtcmVhbC1jZXJ0\n-----END CERTIFICATE-----\n",
|
||||
CertSerial: "01",
|
||||
KeyPath: "mem-NEVER-ISSUED",
|
||||
KeyAlg: "ECDSA-P256",
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour), // far future, NOT in rotation grace
|
||||
}
|
||||
if err := repo.Put(ctx, stale); err != nil {
|
||||
t.Fatalf("seed stale row: %v", err)
|
||||
}
|
||||
repo.putCount = 0 // reset so the bootstrap-triggered Put is the only one we count
|
||||
|
||||
// First SignOCSPResponse should detect the bad KeyPath, log a warning,
|
||||
// and bootstrap a fresh responder.
|
||||
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(0xBEEF), 0)); err != nil {
|
||||
t.Fatalf("SignOCSPResponse should recover from corrupt key ref, got: %v", err)
|
||||
}
|
||||
if repo.putCount != 1 {
|
||||
t.Errorf("expected fresh bootstrap on corrupt key ref, putCount=%d", repo.putCount)
|
||||
}
|
||||
row := repo.rows["iss-test-local"]
|
||||
if row.CertSerial == "01" {
|
||||
t.Error("responder row was not replaced after corrupt key ref recovery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_DedicatedResponder_KeyDirSetter(t *testing.T) {
|
||||
// Pin the SetOCSPResponderKeyDir path. The MemoryDriver doesn't
|
||||
// honor the dir (it generates in-memory refs), so this is purely a
|
||||
// no-side-effect coverage pin for the setter.
|
||||
conn, _ := newConnectorWithResponderDeps(t)
|
||||
conn.SetOCSPResponderKeyDir(t.TempDir())
|
||||
|
||||
if _, err := conn.SignOCSPResponse(context.Background(), ocspReqFor(big.NewInt(7), 0)); err != nil {
|
||||
t.Fatalf("SignOCSPResponse with key dir set: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_DedicatedResponder_RecoversFromCorruptCertPEM(t *testing.T) {
|
||||
// Companion to the corrupt-key-ref test: this time the key loads
|
||||
// fine but the persisted CertPEM is not a CERTIFICATE block. The
|
||||
// bootstrap should detect via parseSinglePEMCert and re-issue.
|
||||
conn, repo := newConnectorWithResponderDeps(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Generate a real key via the MemoryDriver so the load succeeds, then
|
||||
// pair it with an INVALID cert PEM (PRIVATE KEY block instead of
|
||||
// CERTIFICATE). MemoryDriver.Generate stores the key under a fresh
|
||||
// "mem-N" ref; we capture that ref by triggering a Generate and
|
||||
// pulling the row out of the repo.
|
||||
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(1), 0)); err != nil {
|
||||
t.Fatalf("seed bootstrap: %v", err)
|
||||
}
|
||||
row := repo.rows["iss-test-local"]
|
||||
row.CertPEM = "-----BEGIN PRIVATE KEY-----\nbm9wZQ==\n-----END PRIVATE KEY-----\n"
|
||||
repo.rows["iss-test-local"] = row
|
||||
repo.putCount = 0
|
||||
|
||||
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(2), 0)); err != nil {
|
||||
t.Fatalf("SignOCSPResponse should recover from corrupt cert PEM, got: %v", err)
|
||||
}
|
||||
if repo.putCount != 1 {
|
||||
t.Errorf("expected fresh bootstrap on corrupt cert PEM, putCount=%d", repo.putCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignOCSPResponse_DedicatedResponder_RotatesWithinGrace(t *testing.T) {
|
||||
conn, repo := newConnectorWithResponderDeps(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Use a short validity + matching grace so the first bootstrap
|
||||
// produces a cert that immediately falls inside the rotation
|
||||
// window on the next call. validity = 5m, grace = 10m → freshly-
|
||||
// bootstrapped cert expires in 5m which is < 10m grace → rotate.
|
||||
conn.SetOCSPResponderValidity(5 * time.Minute)
|
||||
conn.SetOCSPResponderRotationGrace(10 * time.Minute)
|
||||
|
||||
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(1), 0)); err != nil {
|
||||
t.Fatalf("first SignOCSPResponse: %v", err)
|
||||
}
|
||||
firstSerial := repo.rows["iss-test-local"].CertSerial
|
||||
|
||||
// Second call: rotation triggers because the first cert is in the
|
||||
// grace window. The new row's RotatedFrom should equal the first
|
||||
// cert's serial.
|
||||
if _, err := conn.SignOCSPResponse(ctx, ocspReqFor(big.NewInt(2), 0)); err != nil {
|
||||
t.Fatalf("second SignOCSPResponse (rotation): %v", err)
|
||||
}
|
||||
if repo.putCount < 2 {
|
||||
t.Fatalf("expected rotation to trigger a second Put, got putCount=%d", repo.putCount)
|
||||
}
|
||||
row := repo.rows["iss-test-local"]
|
||||
if row.CertSerial == firstSerial {
|
||||
t.Errorf("CertSerial unchanged across rotation: %q", row.CertSerial)
|
||||
}
|
||||
if row.RotatedFrom != firstSerial {
|
||||
t.Errorf("RotatedFrom = %q, want %q (the first cert's serial)", row.RotatedFrom, firstSerial)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,27 @@ type CRLCacheRepository interface {
|
||||
ListGenerationEvents(ctx context.Context, issuerID string, limit int) ([]*domain.CRLGenerationEvent, error)
|
||||
}
|
||||
|
||||
// OCSPResponderRepository persists per-issuer OCSP-responder cert + key
|
||||
// pointers for the dedicated-responder-cert flow (RFC 6960 §2.6 +
|
||||
// §4.2.2.2). One row per issuer; rotation overwrites in place.
|
||||
//
|
||||
// Schema lives in migrations/000020_ocsp_responder.up.sql.
|
||||
type OCSPResponderRepository interface {
|
||||
// Get returns the current responder for an issuer, or (nil, nil)
|
||||
// when no row exists yet (caller treats as "needs bootstrap").
|
||||
Get(ctx context.Context, issuerID string) (*domain.OCSPResponder, error)
|
||||
|
||||
// Put inserts or replaces the responder row for an issuer. ON
|
||||
// CONFLICT updates every field so a rotation atomically replaces
|
||||
// the prior cert without a window where the row is missing.
|
||||
Put(ctx context.Context, responder *domain.OCSPResponder) error
|
||||
|
||||
// ListExpiring returns responders whose not_after is within the
|
||||
// given grace window (used by the rotation scheduler to find
|
||||
// responders due for rotation).
|
||||
ListExpiring(ctx context.Context, grace time.Duration, now time.Time) ([]*domain.OCSPResponder, error)
|
||||
}
|
||||
|
||||
// IssuerRepository defines operations for managing certificate issuers.
|
||||
type IssuerRepository interface {
|
||||
// List returns all issuers, optionally filtered.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// OCSPResponderRepository implements repository.OCSPResponderRepository.
|
||||
//
|
||||
// One row per issuer; rotation is an upsert (no historical rows kept —
|
||||
// operators have the audit log + the previous CertSerial recorded in
|
||||
// rotated_from for the most-recent rotation).
|
||||
type OCSPResponderRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewOCSPResponderRepository creates a new repository.
|
||||
func NewOCSPResponderRepository(db *sql.DB) *OCSPResponderRepository {
|
||||
return &OCSPResponderRepository{db: db}
|
||||
}
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ repository.OCSPResponderRepository = (*OCSPResponderRepository)(nil)
|
||||
|
||||
// Get returns the current responder row, or (nil, nil) when missing.
|
||||
func (r *OCSPResponderRepository) Get(ctx context.Context, issuerID string) (*domain.OCSPResponder, error) {
|
||||
const query = `
|
||||
SELECT issuer_id, cert_pem, cert_serial, key_path, key_alg,
|
||||
not_before, not_after, COALESCE(rotated_from, ''),
|
||||
created_at, updated_at
|
||||
FROM ocsp_responders
|
||||
WHERE issuer_id = $1
|
||||
`
|
||||
var resp domain.OCSPResponder
|
||||
err := r.db.QueryRowContext(ctx, query, issuerID).Scan(
|
||||
&resp.IssuerID,
|
||||
&resp.CertPEM,
|
||||
&resp.CertSerial,
|
||||
&resp.KeyPath,
|
||||
&resp.KeyAlg,
|
||||
&resp.NotBefore,
|
||||
&resp.NotAfter,
|
||||
&resp.RotatedFrom,
|
||||
&resp.CreatedAt,
|
||||
&resp.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ocsp_responders get %q: %w", issuerID, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Put upserts the responder row. The DB sets created_at on first insert
|
||||
// (default NOW()) and updated_at on every write (NOW() in the SET clause).
|
||||
// Callers leave CreatedAt + UpdatedAt zero; the DB authoritative for both.
|
||||
func (r *OCSPResponderRepository) Put(ctx context.Context, responder *domain.OCSPResponder) error {
|
||||
if responder == nil {
|
||||
return errors.New("ocsp_responders put: nil responder")
|
||||
}
|
||||
if responder.IssuerID == "" {
|
||||
return errors.New("ocsp_responders put: empty issuer_id")
|
||||
}
|
||||
const query = `
|
||||
INSERT INTO ocsp_responders (
|
||||
issuer_id, cert_pem, cert_serial, key_path, key_alg,
|
||||
not_before, not_after, rotated_from, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NOW())
|
||||
ON CONFLICT (issuer_id) DO UPDATE SET
|
||||
cert_pem = EXCLUDED.cert_pem,
|
||||
cert_serial = EXCLUDED.cert_serial,
|
||||
key_path = EXCLUDED.key_path,
|
||||
key_alg = EXCLUDED.key_alg,
|
||||
not_before = EXCLUDED.not_before,
|
||||
not_after = EXCLUDED.not_after,
|
||||
rotated_from = EXCLUDED.rotated_from,
|
||||
updated_at = NOW()
|
||||
`
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
responder.IssuerID,
|
||||
responder.CertPEM,
|
||||
responder.CertSerial,
|
||||
responder.KeyPath,
|
||||
responder.KeyAlg,
|
||||
responder.NotBefore,
|
||||
responder.NotAfter,
|
||||
responder.RotatedFrom,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ocsp_responders put %q: %w", responder.IssuerID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListExpiring returns responders whose not_after is at or before
|
||||
// (now + grace). Used by the rotation scheduler to find responders due
|
||||
// for rotation. Ordered by not_after ASC so earliest-expiring is first.
|
||||
func (r *OCSPResponderRepository) ListExpiring(ctx context.Context, grace time.Duration, now time.Time) ([]*domain.OCSPResponder, error) {
|
||||
threshold := now.Add(grace)
|
||||
const query = `
|
||||
SELECT issuer_id, cert_pem, cert_serial, key_path, key_alg,
|
||||
not_before, not_after, COALESCE(rotated_from, ''),
|
||||
created_at, updated_at
|
||||
FROM ocsp_responders
|
||||
WHERE not_after <= $1
|
||||
ORDER BY not_after ASC
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, query, threshold)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ocsp_responders list_expiring: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*domain.OCSPResponder
|
||||
for rows.Next() {
|
||||
var resp domain.OCSPResponder
|
||||
if err := rows.Scan(
|
||||
&resp.IssuerID,
|
||||
&resp.CertPEM,
|
||||
&resp.CertSerial,
|
||||
&resp.KeyPath,
|
||||
&resp.KeyAlg,
|
||||
&resp.NotBefore,
|
||||
&resp.NotAfter,
|
||||
&resp.RotatedFrom,
|
||||
&resp.CreatedAt,
|
||||
&resp.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("ocsp_responders list_expiring scan: %w", err)
|
||||
}
|
||||
out = append(out, &resp)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("ocsp_responders list_expiring iterate: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 000020_ocsp_responder.down.sql — reverses 000020_ocsp_responder.up.sql.
|
||||
|
||||
DROP INDEX IF EXISTS idx_ocsp_responders_not_after;
|
||||
DROP TABLE IF EXISTS ocsp_responders;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- 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);
|
||||
Reference in New Issue
Block a user