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:
shankar0123
2026-04-30 04:55:06 +00:00
parent c98d83f596
commit 3d15a3e5af
11 changed files with 584 additions and 3 deletions
@@ -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)
+30 -1
View File
@@ -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") {
+6
View File
@@ -123,4 +123,10 @@ type OCSPSignRequest struct {
RevocationReason int
ThisUpdate time.Time
NextUpdate time.Time
// Nonce — RFC 6960 §4.4.1 OCSP-nonce extension echo. When non-nil,
// the responder MUST include this value in the response's
// singleExtensions field. When nil, the response MUST NOT carry
// a nonce extension (back-compat with relying parties that don't
// understand it). Production hardening II Phase 1.
Nonce []byte
}
+17
View File
@@ -1050,6 +1050,23 @@ func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignReq
template.Status = ocsp.Unknown
}
// Production hardening II Phase 1.4: echo the request's nonce in
// the response's singleExtensions field per RFC 6960 §4.4.1.
// The handler walks the inbound request's extensions and populates
// req.Nonce when a well-formed nonce extension is present; we just
// re-marshal it here as the extnValue OCTET STRING.
if len(req.Nonce) > 0 {
nonceExtnValue, err := asn1.Marshal(req.Nonce)
if err != nil {
return nil, fmt.Errorf("marshal OCSP nonce extension: %w", err)
}
template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{
Id: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 2}, // id-pkix-ocsp-nonce
Critical: false, // RFC 6960 §4.4 — nonce is non-critical
Value: nonceExtnValue,
})
}
// ocsp.CreateResponse(issuer, responder, template, signer):
// - issuer: always c.caCert (the CA that issued the cert
// being checked, NOT the responder cert)
+18
View File
@@ -98,7 +98,21 @@ func (s *CAOperationsSvc) GenerateDERCRL(ctx context.Context, issuerID string) (
}
// GetOCSPResponse generates a signed OCSP response for the given certificate serial.
// Back-compat wrapper around GetOCSPResponseWithNonce: passes nil nonce,
// which produces a response without the RFC 6960 §4.4.1 nonce extension.
// Older callers that don't carry a nonce see no behavior change.
func (s *CAOperationsSvc) GetOCSPResponse(ctx context.Context, issuerID string, serialHex string) ([]byte, error) {
return s.GetOCSPResponseWithNonce(ctx, issuerID, serialHex, nil)
}
// GetOCSPResponseWithNonce generates a signed OCSP response for the
// given certificate serial. When nonce is non-nil, the responder echoes
// it in the response per RFC 6960 §4.4.1 (nonce extension). nil nonce
// omits the extension entirely (back-compat with relying parties that
// do not include one).
//
// Production hardening II Phase 1.
func (s *CAOperationsSvc) GetOCSPResponseWithNonce(ctx context.Context, issuerID string, serialHex string, nonce []byte) ([]byte, error) {
if s.revocationRepo == nil {
return nil, fmt.Errorf("revocation repository not configured")
}
@@ -133,6 +147,7 @@ func (s *CAOperationsSvc) GetOCSPResponse(ctx context.Context, issuerID string,
CertStatus: 0, // good — short-lived exemption
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
Nonce: nonce,
})
}
}
@@ -150,6 +165,7 @@ func (s *CAOperationsSvc) GetOCSPResponse(ctx context.Context, issuerID string,
RevocationReason: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
Nonce: nonce,
})
}
@@ -175,6 +191,7 @@ func (s *CAOperationsSvc) GetOCSPResponse(ctx context.Context, issuerID string,
CertStatus: 2, // unknown
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
Nonce: nonce,
})
}
}
@@ -185,5 +202,6 @@ func (s *CAOperationsSvc) GetOCSPResponse(ctx context.Context, issuerID string,
CertStatus: 0, // good
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
Nonce: nonce,
})
}
+10 -2
View File
@@ -521,12 +521,20 @@ func (s *CertificateService) GenerateDERCRL(ctx context.Context, issuerID string
}
// GetOCSPResponse generates a signed OCSP response for the given certificate serial.
// Delegates to CAOperationsSvc.
// Back-compat wrapper around GetOCSPResponseWithNonce; passes nil nonce so the
// response omits the RFC 6960 §4.4.1 nonce extension.
func (s *CertificateService) GetOCSPResponse(ctx context.Context, issuerID string, serialHex string) ([]byte, error) {
return s.GetOCSPResponseWithNonce(ctx, issuerID, serialHex, nil)
}
// GetOCSPResponseWithNonce generates a signed OCSP response and (when
// nonce != nil) echoes the nonce in the response per RFC 6960 §4.4.1.
// Production hardening II Phase 1.
func (s *CertificateService) GetOCSPResponseWithNonce(ctx context.Context, issuerID string, serialHex string, nonce []byte) ([]byte, error) {
if s.caSvc == nil {
return nil, fmt.Errorf("CA operations service not configured")
}
return s.caSvc.GetOCSPResponse(ctx, issuerID, serialHex)
return s.caSvc.GetOCSPResponseWithNonce(ctx, issuerID, serialHex, nonce)
}
// GetCertificateDeployments returns all deployment targets for a certificate (M20).
+1
View File
@@ -104,6 +104,7 @@ func (a *IssuerConnectorAdapter) SignOCSPResponse(ctx context.Context, req OCSPS
RevocationReason: req.RevocationReason,
ThisUpdate: req.ThisUpdate,
NextUpdate: req.NextUpdate,
Nonce: req.Nonce, // RFC 6960 §4.4.1 echo (production hardening II Phase 1)
})
}
+110
View File
@@ -0,0 +1,110 @@
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(),
}
}
+170
View File
@@ -0,0 +1,170 @@
package service
import (
"encoding/asn1"
"errors"
)
// Production hardening II Phase 1.1 — OCSP nonce extension parsing.
//
// RFC 6960 §4.4.1 defines the optional id-pkix-ocsp-nonce extension
// (OID 1.3.6.1.5.5.7.48.1.2) that defends against replay attacks.
// When present in the request, the responder MUST echo the same
// nonce value in the response. When absent, the response MUST NOT
// include a nonce.
//
// `golang.org/x/crypto/ocsp.Request` does NOT expose the request's
// extensions field — we have to walk the raw DER ourselves to extract
// the nonce. The grammar (RFC 6960 §4.1.1):
//
// OCSPRequest ::= SEQUENCE {
// tbsRequest TBSRequest,
// optionalSignature [0] EXPLICIT Signature OPTIONAL
// }
// TBSRequest ::= SEQUENCE {
// version [0] EXPLICIT Version DEFAULT v1,
// requestorName [1] EXPLICIT GeneralName OPTIONAL,
// requestList SEQUENCE OF Request,
// requestExtensions [2] EXPLICIT Extensions OPTIONAL
// }
// Extension ::= SEQUENCE {
// extnID OBJECT IDENTIFIER,
// critical BOOLEAN DEFAULT FALSE,
// extnValue OCTET STRING
// }
//
// The nonce extension's extnValue is itself a DER-encoded OCTET STRING
// containing the nonce bytes (per RFC 6960 §4.4.1 — "The value of the
// extension SHALL be the value of a Nonce ::= OCTET STRING").
// OIDOCSPNonce is the id-pkix-ocsp-nonce extension OID (RFC 6960 §4.4.1).
var OIDOCSPNonce = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 2}
// MaxOCSPNonceLength is the per-CA/B-Forum-guidance cap on the nonce
// payload size. Larger nonces are rejected as malformed (Phase 1
// frozen decision). The CA/B Forum Baseline Requirements §4.10.2
// notes that nonces SHOULD be at most 32 octets.
const MaxOCSPNonceLength = 32
// ErrOCSPNonceMalformed is returned when the request carries a nonce
// extension but the nonce value violates the documented constraints
// (empty, oversized, or unparseable). The handler maps this to an
// "unauthorized" OCSP response (status 6 per RFC 6960 §2.3) rather
// than echoing potentially-malicious bytes back to the relying party.
var ErrOCSPNonceMalformed = errors.New("OCSP request: nonce extension malformed")
// ParseOCSPRequestNonce extracts the nonce value (if any) from the
// OCSP request's TBSRequest.requestExtensions field.
//
// Returns:
// - (nonceBytes, true, nil) — well-formed nonce, echo it.
// - (nil, false, nil) — no nonce extension present (back-compat).
// - (nil, false, ErrOCSPNonceMalformed) — nonce present but malformed
// (zero length OR > MaxOCSPNonceLength). Handler MUST NOT echo;
// return an unauthorized OCSP response.
//
// The function is tolerant of arbitrary OCSP requests including those
// with an optionalSignature: it parses the OCSPRequest envelope first,
// then walks tbsRequest.
func ParseOCSPRequestNonce(reqDER []byte) (nonce []byte, present bool, err error) {
// OCSPRequest ::= SEQUENCE { tbsRequest, [0] OPTIONAL signature }
var ocspReq asn1.RawValue
if _, err := asn1.Unmarshal(reqDER, &ocspReq); err != nil {
// Not our problem — ocsp.ParseRequest already validated this
// path. Return "no nonce" rather than surfacing a redundant
// parse error to the caller.
return nil, false, nil
}
// Walk the SEQUENCE: tbsRequest is the first element.
var tbsRequest asn1.RawValue
rest, err := asn1.Unmarshal(ocspReq.Bytes, &tbsRequest)
if err != nil {
return nil, false, nil
}
_ = rest // optionalSignature ignored — we never validate request signatures
// TBSRequest ::= SEQUENCE { [0] version OPTIONAL, [1] requestorName
// OPTIONAL, requestList, [2] requestExtensions
// OPTIONAL }
//
// Walk the elements; pick out the [2] EXPLICIT tag.
tail := tbsRequest.Bytes
for len(tail) > 0 {
var elem asn1.RawValue
var rerr error
tail, rerr = asn1.Unmarshal(tail, &elem)
if rerr != nil {
return nil, false, nil
}
if elem.Class != asn1.ClassContextSpecific || elem.Tag != 2 {
continue
}
// elem.Bytes is the inner Extensions (which is a SEQUENCE OF
// Extension). Unmarshal into []pkix.Extension-equivalent.
return extractNonceFromExtensions(elem.Bytes)
}
return nil, false, nil
}
// extractNonceFromExtensions walks a SEQUENCE OF Extension looking for
// the id-pkix-ocsp-nonce OID. Returns the OCTET STRING contents on
// match, or (nil, false, nil) on no-match.
func extractNonceFromExtensions(extBytes []byte) ([]byte, bool, error) {
// extBytes is the SEQUENCE OF Extension wrapped in its outer
// SEQUENCE tag. Unwrap once.
var extSeq asn1.RawValue
if _, err := asn1.Unmarshal(extBytes, &extSeq); err != nil {
return nil, false, nil
}
tail := extSeq.Bytes
for len(tail) > 0 {
var ext struct {
ExtnID asn1.ObjectIdentifier
Critical bool `asn1:"optional"`
ExtnValue []byte
}
var rerr error
tail, rerr = asn1.Unmarshal(tail, &ext)
if rerr != nil {
// Try the no-Critical form (DER allows the BOOLEAN to be
// omitted entirely when DEFAULT FALSE).
var ext2 struct {
ExtnID asn1.ObjectIdentifier
ExtnValue []byte
}
tail2, rerr2 := asn1.Unmarshal(tail, &ext2)
if rerr2 != nil {
return nil, false, nil
}
tail = tail2
ext.ExtnID = ext2.ExtnID
ext.ExtnValue = ext2.ExtnValue
}
if !ext.ExtnID.Equal(OIDOCSPNonce) {
continue
}
// extnValue is itself a DER-encoded OCTET STRING (per RFC 6960
// §4.4.1: "The value of the extension SHALL be the value of a
// Nonce ::= OCTET STRING"). Unwrap once more.
var nonce []byte
if _, err := asn1.Unmarshal(ext.ExtnValue, &nonce); err != nil {
return nil, false, ErrOCSPNonceMalformed
}
if len(nonce) == 0 {
return nil, false, ErrOCSPNonceMalformed
}
if len(nonce) > MaxOCSPNonceLength {
return nil, false, ErrOCSPNonceMalformed
}
return nonce, true, nil
}
return nil, false, nil
}
// (The inverse — wrapping the nonce bytes back into an extnValue
// OCTET STRING — happens inline in the local issuer's
// SignOCSPResponse, where the response's ExtraExtensions field is
// populated. There's no need for a separate marshaling helper here:
// asn1.Marshal([]byte) produces the canonical OCTET STRING DER and
// is the entire extnValue payload per RFC 6960 §4.4.1.)
+201
View File
@@ -0,0 +1,201 @@
package service
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"errors"
"math/big"
"testing"
"golang.org/x/crypto/ocsp"
)
// Production hardening II Phase 1 — OCSP nonce parser tests.
//
// The parser walks raw DER (golang.org/x/crypto/ocsp.Request doesn't
// expose request extensions). These tests pin every documented
// failure mode and the happy-path round-trip:
//
// - Request without nonce extension -> (nil, false, nil)
// - Request with well-formed nonce -> (nonce, true, nil)
// - Empty nonce -> (nil, false, ErrOCSPNonceMalformed)
// - Oversized nonce (>32 bytes) -> (nil, false, ErrOCSPNonceMalformed)
// - Garbage extnValue -> (nil, false, ErrOCSPNonceMalformed)
// - Garbage TBSRequest -> (nil, false, nil) (not our problem)
// buildOCSPRequestWithNonce constructs an OCSP request DER with the
// given nonce bytes wrapped in the canonical extnValue OCTET STRING
// envelope. When nonce is nil, no extension is added.
func buildOCSPRequestWithNonce(t *testing.T, nonce []byte) []byte {
t.Helper()
// Build a real issuer cert so ocsp.CreateRequest has something to
// hash for the IssuerNameHash + IssuerKeyHash fields.
priv, err := rsa.GenerateKey(rand.Reader, 1024) //nolint:gosec // test fixture, not security-relevant
if err != nil {
t.Fatalf("genkey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Test Issuer"},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("createcert: %v", err)
}
issuer, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parsecert: %v", err)
}
// Build the raw OCSP request body via golang.org/x/crypto/ocsp,
// then patch the requestExtensions field if a nonce is requested.
// ocsp.CreateRequest doesn't accept extensions, so we re-marshal
// the TBSRequest with an Extensions slice spliced in.
reqDER, err := ocsp.CreateRequest(&x509.Certificate{SerialNumber: big.NewInt(42)}, issuer, nil)
if err != nil {
t.Fatalf("ocsp.CreateRequest: %v", err)
}
if nonce == nil {
return reqDER
}
// Splice in the nonce extension by hand-marshaling a new TBSRequest.
// Pull the existing TBSRequest, append a [2] EXPLICIT Extensions
// element containing one Extension (id-pkix-ocsp-nonce, OCTET
// STRING(nonce)).
extnValue, err := asn1.Marshal(nonce) // OCTET STRING wrap
if err != nil {
t.Fatalf("marshal nonce extnValue: %v", err)
}
nonceExt := struct {
ExtnID asn1.ObjectIdentifier
ExtnValue []byte
}{
ExtnID: OIDOCSPNonce,
ExtnValue: extnValue,
}
extDER, err := asn1.Marshal([]any{nonceExt})
if err != nil {
t.Fatalf("marshal extensions: %v", err)
}
// Wrap in [2] EXPLICIT
exposed := asn1.RawValue{
Class: asn1.ClassContextSpecific,
Tag: 2,
IsCompound: true,
Bytes: extDER,
}
expDER, err := asn1.Marshal(exposed)
if err != nil {
t.Fatalf("marshal exposed: %v", err)
}
// Splice: parse OCSPRequest, append expDER to TBSRequest's Bytes,
// re-marshal as a SEQUENCE.
var ocspReqRV asn1.RawValue
if _, err := asn1.Unmarshal(reqDER, &ocspReqRV); err != nil {
t.Fatalf("unmarshal OCSPRequest envelope: %v", err)
}
var tbsRV asn1.RawValue
rest, err := asn1.Unmarshal(ocspReqRV.Bytes, &tbsRV)
if err != nil {
t.Fatalf("unmarshal TBSRequest: %v", err)
}
// Append expDER to tbsRV.Bytes
newTBS := append(append([]byte{}, tbsRV.Bytes...), expDER...)
// Re-marshal the TBSRequest SEQUENCE
newTBSRV, err := asn1.Marshal(asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagSequence, IsCompound: true, Bytes: newTBS})
if err != nil {
t.Fatalf("re-marshal TBSRequest: %v", err)
}
// Re-marshal the outer OCSPRequest = TBSRequest || (rest, e.g. signature)
newOuter := append(append([]byte{}, newTBSRV...), rest...)
newOuterRV, err := asn1.Marshal(asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagSequence, IsCompound: true, Bytes: newOuter})
if err != nil {
t.Fatalf("re-marshal OCSPRequest: %v", err)
}
return newOuterRV
}
func TestOCSPNonce_RequestWithoutNonce_ReturnsNoneNoError(t *testing.T) {
reqDER := buildOCSPRequestWithNonce(t, nil)
nonce, present, err := ParseOCSPRequestNonce(reqDER)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if present {
t.Errorf("expected present=false, got true")
}
if nonce != nil {
t.Errorf("expected nil nonce, got %x", nonce)
}
}
func TestOCSPNonce_RequestWithWellFormedNonce_EchoBytesMatchInput(t *testing.T) {
want := []byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x00, 0x11, 0x22, 0x33}
reqDER := buildOCSPRequestWithNonce(t, want)
nonce, present, err := ParseOCSPRequestNonce(reqDER)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !present {
t.Errorf("expected present=true")
}
if string(nonce) != string(want) {
t.Errorf("nonce mismatch: got %x, want %x", nonce, want)
}
}
func TestOCSPNonce_EmptyNonce_RejectedAsMalformed(t *testing.T) {
reqDER := buildOCSPRequestWithNonce(t, []byte{})
_, _, err := ParseOCSPRequestNonce(reqDER)
if !errors.Is(err, ErrOCSPNonceMalformed) {
t.Errorf("expected ErrOCSPNonceMalformed, got %v", err)
}
}
func TestOCSPNonce_OversizedNonce_RejectedAsMalformed(t *testing.T) {
// 33 bytes — one more than MaxOCSPNonceLength
oversize := make([]byte, MaxOCSPNonceLength+1)
for i := range oversize {
oversize[i] = byte(i)
}
reqDER := buildOCSPRequestWithNonce(t, oversize)
_, _, err := ParseOCSPRequestNonce(reqDER)
if !errors.Is(err, ErrOCSPNonceMalformed) {
t.Errorf("expected ErrOCSPNonceMalformed for nonce of len %d, got %v", len(oversize), err)
}
}
func TestOCSPNonce_GarbageDER_ReturnsNoneNoError(t *testing.T) {
// Random garbage that's not even an ASN.1 SEQUENCE — caller already
// validated via ocsp.ParseRequest, so a parse failure here is not
// our problem; return "no nonce" rather than surfacing redundant
// parse errors.
_, present, err := ParseOCSPRequestNonce([]byte{0xff, 0x00, 0x42})
if err != nil {
t.Errorf("garbage DER should not surface error, got %v", err)
}
if present {
t.Errorf("garbage DER should not produce present=true")
}
}
func TestOCSPNonce_BoundaryNonce_32BytesAccepted(t *testing.T) {
// Exactly MaxOCSPNonceLength — must be accepted.
exact := make([]byte, MaxOCSPNonceLength)
for i := range exact {
exact[i] = 0xab
}
reqDER := buildOCSPRequestWithNonce(t, exact)
nonce, present, err := ParseOCSPRequestNonce(reqDER)
if err != nil {
t.Fatalf("32-byte nonce should be accepted, got %v", err)
}
if !present || len(nonce) != MaxOCSPNonceLength {
t.Errorf("expected present=true with 32-byte nonce; got present=%v len=%d", present, len(nonce))
}
}
+5
View File
@@ -100,6 +100,11 @@ type OCSPSignRequest struct {
RevocationReason int
ThisUpdate time.Time
NextUpdate time.Time
// Nonce — RFC 6960 §4.4.1 nonce-extension echo. When non-nil, the
// responder includes this value in the response's singleExtensions
// field. Production hardening II Phase 1 — mirrors the same-named
// field on internal/connector/issuer/interface.go::OCSPSignRequest.
Nonce []byte
}
// NewRenewalService creates a new renewal service.