Files
certctl/internal/service/ocsp_counters.go
T
shankar0123 3d15a3e5af feat(ocsp): RFC 6960 §4.4.1 nonce extension support — echo client nonce in response, reject malformed
Production hardening II Phase 1.

The OCSP responder previously ignored the request's nonce extension
entirely, leaving relying parties vulnerable to replay attacks. RFC
6960 §4.4.1 defines the OPTIONAL id-pkix-ocsp-nonce extension (OID
1.3.6.1.5.5.7.48.1.2): when present in the request, the responder
MUST echo the same value in the response; when absent, no nonce in
the response (back-compat with relying parties that don't send one).

NEW internal/service/ocsp_nonce.go: ParseOCSPRequestNonce walks raw
DER (golang.org/x/crypto/ocsp.Request doesn't expose the request's
extensions field — the library only exposes IssuerNameHash +
IssuerKeyHash + SerialNumber). Returns one of three states:
  - (nil, false, nil) — no nonce extension in request
  - (nonce, true, nil) — well-formed nonce, ≤ MaxOCSPNonceLength (32)
  - (nil, false, ErrOCSPNonceMalformed) — empty or oversized

NEW internal/service/ocsp_counters.go: sync/atomic counter table for
OCSP request lifecycle (request_get/post, request_success/invalid,
nonce_echoed, nonce_malformed, rate_limited, ...). Mirrors the EST/
SCEP counter pattern; Phase 8 wires these into /metrics/prometheus.

CertSrv types extended:
  - internal/connector/issuer/interface.go::OCSPSignRequest gains
    Nonce []byte field.
  - internal/service/renewal.go::OCSPSignRequest (the service-layer
    duplicate used by ca_operations.go) gains the same field.
  - internal/service/issuer_adapter.go bridges the two.

Service path: CAOperationsSvc.GetOCSPResponseWithNonce(ctx, issuerID,
serialHex, nonce) is the new entry point that plumbs the nonce
through every signing site (good / revoked / unknown / short-lived).
The legacy GetOCSPResponse becomes a nil-nonce wrapper for back-
compat — every existing caller (tests, the GET handler) sees no
behavior change.

CertificateService gains the same WithNonce variant; the handler
interface adds it to the contract. MockCertificateService in tests
extended with the new method (delegates to the legacy fn when no
override is set, so existing tests that don't care about the nonce
keep working).

Local issuer's SignOCSPResponse appends the id-pkix-ocsp-nonce
extension (non-Critical per RFC 6960 §4.4) to the response template's
ExtraExtensions when req.Nonce != nil. The extnValue is the nonce
bytes wrapped in an OCTET STRING per RFC 6960 §4.4.1.

POST OCSP handler (HandleOCSPPost):
  - After ocsp.ParseRequest succeeds, calls ParseOCSPRequestNonce on
    the raw body to extract the optional nonce.
  - On ErrOCSPNonceMalformed (empty or > 32 bytes): writes an
    'unauthorized' OCSP response (status 6 per RFC 6960 §2.3) using
    the canonical ocsp.UnauthorizedErrorResponse from x/crypto/ocsp.
    Does NOT echo malicious bytes back.
  - On well-formed nonce: passes it through GetOCSPResponseWithNonce.
  - On no nonce: nil passed through; back-compat preserved.

GET OCSP handler unchanged — the GET form has no body to carry a
nonce extension.

6 new tests in internal/service/ocsp_nonce_test.go pin every
documented failure mode + the 32-byte boundary. The test fixture
builds an OCSPRequest via golang.org/x/crypto/ocsp.CreateRequest then
splices in a [2] EXPLICIT Extensions element by hand (the library
doesn't expose extension construction either).

Pre-commit verification: gofmt clean, go vet clean across affected
packages, go test -short -count=1 green for service/ + handler/ +
connector/issuer/local/. No new env vars introduced (Phase 1 is
always-on per RFC; no operator opt-out).
2026-04-30 04:55:06 +00:00

111 lines
4.5 KiB
Go

package service
import "sync/atomic"
// Production hardening II Phase 1.3 — OCSP per-request counters.
//
// Mirrors the pattern in est_counters.go and scep_counters.go:
// sync/atomic primitives keep the hot path lock-free, while a snapshot
// accessor produces a stable map for the Prometheus exposition handler
// (Phase 8).
//
// Counter labels are stable strings — the Prometheus phase converts
// them into `certctl_ocsp_<label>_total` metric names. Adding a new
// label here without also adding it to the Prometheus exposer would
// be a "silent counter" bug; the exposer test in Phase 8 enumerates
// the labels to defend against drift.
// OCSPCounters is the shared counter table for OCSP request processing.
// A single instance lives on the certificate service (or the OCSP
// cache service when present) and ticks every OCSP request through
// its lifecycle:
//
// - request_get / request_post — incremented per inbound request
// by transport.
// - request_success — incremented when a signed OCSP response is
// written to the wire (regardless of cert status: good / revoked
// / unknown all count as success).
// - request_invalid — malformed request body (ocsp.ParseRequest
// failure) or path-extraction failure.
// - issuer_not_found — request's issuer_id doesn't resolve to a
// known issuer connector.
// - cert_not_found — request's serial doesn't resolve to any
// issued cert.
// - signing_failed — issuer connector returned an error.
// - nonce_echoed — request carried a well-formed nonce extension
// and the response echoed it (RFC 6960 §4.4.1 happy path).
// - nonce_malformed — request carried a nonce extension that was
// too long (>32 bytes, per CA/B Forum guidance) or empty. The
// response is unauthorized (status 6).
// - rate_limited — Phase 3 limiter tripped; the response is
// unauthorized (status 6) plus a Retry-After hint.
//
// New labels MUST also be added to OCSPCounters.Snapshot AND to the
// Prometheus exposer in Phase 8.
type OCSPCounters struct {
requestGET atomic.Uint64
requestPOST atomic.Uint64
requestSuccess atomic.Uint64
requestInvalid atomic.Uint64
issuerNotFound atomic.Uint64
certNotFound atomic.Uint64
signingFailed atomic.Uint64
nonceEchoed atomic.Uint64
nonceMalformed atomic.Uint64
rateLimited atomic.Uint64
}
// NewOCSPCounters constructs a zero-value counter table. The caller
// holds it for the process lifetime; counters are never reset.
func NewOCSPCounters() *OCSPCounters {
return &OCSPCounters{}
}
// IncRequestGET ticks the GET-form request counter.
func (c *OCSPCounters) IncRequestGET() { c.requestGET.Add(1) }
// IncRequestPOST ticks the POST-form request counter.
func (c *OCSPCounters) IncRequestPOST() { c.requestPOST.Add(1) }
// IncRequestSuccess ticks the response-written counter.
func (c *OCSPCounters) IncRequestSuccess() { c.requestSuccess.Add(1) }
// IncRequestInvalid ticks the parse-failure counter.
func (c *OCSPCounters) IncRequestInvalid() { c.requestInvalid.Add(1) }
// IncIssuerNotFound ticks the unknown-issuer counter.
func (c *OCSPCounters) IncIssuerNotFound() { c.issuerNotFound.Add(1) }
// IncCertNotFound ticks the unknown-serial counter.
func (c *OCSPCounters) IncCertNotFound() { c.certNotFound.Add(1) }
// IncSigningFailed ticks the issuer-error counter.
func (c *OCSPCounters) IncSigningFailed() { c.signingFailed.Add(1) }
// IncNonceEchoed ticks the well-formed-nonce-echoed counter.
func (c *OCSPCounters) IncNonceEchoed() { c.nonceEchoed.Add(1) }
// IncNonceMalformed ticks the bad-nonce-rejected counter.
func (c *OCSPCounters) IncNonceMalformed() { c.nonceMalformed.Add(1) }
// IncRateLimited ticks the limiter-tripped counter.
func (c *OCSPCounters) IncRateLimited() { c.rateLimited.Add(1) }
// Snapshot returns a stable map of label → counter value for the
// Prometheus exposer (Phase 8). The returned map is a copy; concurrent
// counter ticks during the snapshot read are not reflected.
func (c *OCSPCounters) Snapshot() map[string]uint64 {
return map[string]uint64{
"request_get": c.requestGET.Load(),
"request_post": c.requestPOST.Load(),
"request_success": c.requestSuccess.Load(),
"request_invalid": c.requestInvalid.Load(),
"issuer_not_found": c.issuerNotFound.Load(),
"cert_not_found": c.certNotFound.Load(),
"signing_failed": c.signingFailed.Load(),
"nonce_echoed": c.nonceEchoed.Load(),
"nonce_malformed": c.nonceMalformed.Load(),
"rate_limited": c.rateLimited.Load(),
}
}