mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:21:40 +00:00
3d15a3e5af
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).
111 lines
4.5 KiB
Go
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(),
|
|
}
|
|
}
|