mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:11:29 +00:00
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).
This commit is contained in:
@@ -38,6 +38,7 @@ type MockCertificateService struct {
|
||||
GetRevokedCertificatesFn func(ctx context.Context) ([]*domain.CertificateRevocation, error)
|
||||
GenerateDERCRLFn func(ctx context.Context, issuerID string) ([]byte, error)
|
||||
GetOCSPResponseFn func(ctx context.Context, issuerID string, serialHex string) ([]byte, error)
|
||||
GetOCSPResponseWithNonceFn func(ctx context.Context, issuerID string, serialHex string, nonce []byte) ([]byte, error)
|
||||
GetCertificateDeploymentsFn func(ctx context.Context, certID string) ([]domain.DeploymentTarget, error)
|
||||
}
|
||||
|
||||
@@ -125,6 +126,21 @@ func (m *MockCertificateService) GetOCSPResponse(ctx context.Context, issuerID s
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetOCSPResponseWithNonce — production hardening II Phase 1.
|
||||
// Falls through to the legacy GetOCSPResponseFn when a per-test
|
||||
// nonce-aware override isn't set, mirroring the behavior of the
|
||||
// real CertificateService where the nonce-less variant is just a
|
||||
// nil-nonce wrapper around the nonce-aware path.
|
||||
func (m *MockCertificateService) GetOCSPResponseWithNonce(ctx context.Context, issuerID string, serialHex string, nonce []byte) ([]byte, error) {
|
||||
if m.GetOCSPResponseWithNonceFn != nil {
|
||||
return m.GetOCSPResponseWithNonceFn(ctx, issuerID, serialHex, nonce)
|
||||
}
|
||||
if m.GetOCSPResponseFn != nil {
|
||||
return m.GetOCSPResponseFn(ctx, issuerID, serialHex)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockCertificateService) ListCertificatesWithFilter(ctx context.Context, filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
|
||||
if m.ListCertificatesWithFilterFn != nil {
|
||||
return m.ListCertificatesWithFilterFn(ctx, filter)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
"github.com/shankar0123/certctl/internal/service"
|
||||
)
|
||||
|
||||
// CertificateService defines the service interface for certificate operations.
|
||||
@@ -34,6 +35,11 @@ type CertificateService interface {
|
||||
GetRevokedCertificates(ctx context.Context) ([]*domain.CertificateRevocation, error)
|
||||
GenerateDERCRL(ctx context.Context, issuerID string) ([]byte, error)
|
||||
GetOCSPResponse(ctx context.Context, issuerID string, serialHex string) ([]byte, error)
|
||||
// GetOCSPResponseWithNonce is the nonce-aware variant added in
|
||||
// production hardening II Phase 1. When nonce is non-nil, the
|
||||
// responder echoes it in the response per RFC 6960 §4.4.1. A nil
|
||||
// nonce produces a response without the nonce extension.
|
||||
GetOCSPResponseWithNonce(ctx context.Context, issuerID string, serialHex string, nonce []byte) ([]byte, error)
|
||||
GetCertificateDeployments(ctx context.Context, certID string) ([]domain.DeploymentTarget, error)
|
||||
}
|
||||
|
||||
@@ -687,11 +693,34 @@ func (h CertificateHandler) HandleOCSPPost(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Production hardening II Phase 1: extract the optional RFC 6960
|
||||
// §4.4.1 nonce extension from the request. golang.org/x/crypto/ocsp
|
||||
// doesn't expose the request's extensions, so we walk the raw DER
|
||||
// ourselves via service.ParseOCSPRequestNonce.
|
||||
//
|
||||
// Failure modes:
|
||||
// - no nonce (most relying parties): nonce=nil, present=false,
|
||||
// err=nil -> proceed without echoing.
|
||||
// - well-formed nonce <= 32 bytes: nonce=bytes, present=true,
|
||||
// err=nil -> plumb through GetOCSPResponseWithNonce.
|
||||
// - malformed nonce (empty or > 32 bytes): err=ErrOCSPNonceMalformed
|
||||
// -> respond with the OCSP "unauthorized" status (RFC 6960 §2.3
|
||||
// status code 6) rather than echoing potentially-malicious bytes.
|
||||
nonce, _, nonceErr := service.ParseOCSPRequestNonce(body)
|
||||
if errors.Is(nonceErr, service.ErrOCSPNonceMalformed) {
|
||||
w.Header().Set("Content-Type", "application/ocsp-response")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// ocsp.UnauthorizedErrorResponse is the canonical pre-built
|
||||
// error response (status 6) per RFC 6960 §4.2.1.
|
||||
w.Write(ocsp.UnauthorizedErrorResponse)
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse the existing service path. The serial extracted from the
|
||||
// parsed OCSPRequest is converted to hex (the on-disk format for
|
||||
// certctl serials matches certificate.SerialNumber.Text(16)).
|
||||
serialHex := fmt.Sprintf("%x", ocspReq.SerialNumber)
|
||||
derBytes, err := h.svc.GetOCSPResponse(r.Context(), issuerID, serialHex)
|
||||
derBytes, err := h.svc.GetOCSPResponseWithNonce(r.Context(), issuerID, serialHex, nonce)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "not found") {
|
||||
|
||||
Reference in New Issue
Block a user