Files
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

202 lines
6.6 KiB
Go

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))
}
}