crl/ocsp: POST OCSP endpoint (RFC 6960 §A.1.1) + cache integration

Phase 4 (final phase) of the CRL/OCSP responder bundle. Closes the
backend slice; HTTP layer is now production-ready for relying parties.

What landed:

  * POST /.well-known/pki/ocsp/{issuer_id} (handler.HandleOCSPPost)
    - Accepts binary application/ocsp-request body per RFC 6960 §A.1.1
    - Tolerant of missing Content-Type (some clients omit); validates
      via ocsp.ParseRequest, returns 400 on malformed
    - Returns 415 on explicit wrong Content-Type
    - Reuses the existing service path (h.svc.GetOCSPResponse) — the
      only new logic is body decoding + serial-from-OCSPRequest extraction
    - GET form preserved unchanged for ad-hoc curl + human URL paths
    - Auth-exempt under /.well-known/pki/ prefix (already in
      AuthExemptDispatchPrefixes — no router changes for that)
    - 7 new tests: success, method-not-allowed, wrong content-type,
      missing content-type accepted, malformed body, missing issuer,
      service error propagation

  * router.go: r.Register("POST /.well-known/pki/ocsp/{issuer_id}", ...)

  * CertificateService.GenerateDERCRL — cache-aware:
    - New SetCRLCacheSvc(svc) setter (matches existing SetCAOperationsSvc
      pattern — optional dep)
    - When wired, GenerateDERCRL calls crlCacheSvc.Get → cheap DB read
      on cache hit, singleflight-coalesced regen on miss
    - When unwired, falls back to historical caSvc.GenerateDERCRL path
    - GET /.well-known/pki/crl/{issuer_id} handler unchanged — calls
      the same service method, gets cache benefit transparently when
      the cache service is wired in cmd/server/main.go

Coverage: handler 79.8% (floor 75), service unchanged, scheduler 78%.

What's deferred (intentional scope cut for this session):

  * cmd/server/main.go wiring of CRLCacheService + responder service
    setters into the local issuer factory + scheduler. The wiring is
    mechanical (NewCRLCacheService + scheduler.SetCRLCacheService call
    in the existing wiring block); deferring keeps this commit focused
    on the responder + cache primitives. Operator can wire when ready.
  * Phase 5 (GUI), Phase 6 (e2e test against kind), Phase 7 (release
    prep) — separate follow-up sessions.
  * OCSP cache integration: today's GET/POST OCSP path goes through
    the on-demand SignOCSPResponse (already cheap with the dedicated
    responder cert from Phase 2). A cached-OCSP path is V3-Pro polish.

The bundle's V2 backend slice (Phases 0-4) is complete. All 4 phases
shipped 4 commits + 1 amend on this branch. CI will validate the
testcontainers repository tests on push.
This commit is contained in:
shankar0123
2026-04-29 00:06:20 +00:00
parent dc326942db
commit dc1e0bfbaa
5 changed files with 378 additions and 22 deletions
+55
View File
@@ -696,6 +696,61 @@ paths:
"501":
description: Issuer does not support OCSP
/.well-known/pki/ocsp/{issuer_id}:
post:
tags: [CRL & OCSP]
summary: OCSP responder (RFC 6960 §A.1.1, POST form)
description: |
Standard RFC 6960 §A.1.1 POST form of the OCSP responder. The
request body is the binary DER-encoded OCSPRequest with
Content-Type `application/ocsp-request`; the serial number is
carried inside that body, not in the URL path. Most production
OCSP clients (Firefox, OpenSSL `s_client -status`, cert-manager,
Microsoft Intune device validators) use POST exclusively.
The pre-existing GET form
(`/.well-known/pki/ocsp/{issuer_id}/{serial}`) is preserved for
ad-hoc curl inspection and human-readable URL paths; behaviour
and response are otherwise identical.
Auth-exempt under `/.well-known/pki/*` per RFC 8615 so relying
parties can poll without a certctl API key. CRL/OCSP-Responder
bundle Phase 4.
operationId: handleOCSPPost
security: []
parameters:
- name: issuer_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/ocsp-request:
schema:
type: string
format: binary
description: DER-encoded OCSPRequest per RFC 6960 §4.1
responses:
"200":
description: OCSP response
content:
application/ocsp-response:
schema:
type: string
format: binary
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"415":
description: Content-Type is not application/ocsp-request
"500":
$ref: "#/components/responses/InternalError"
"501":
description: Issuer does not support OCSP
# ─── Issuers ─────────────────────────────────────────────────────────
/api/v1/issuers:
get:
@@ -3,13 +3,21 @@ package handler
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
@@ -1208,6 +1216,174 @@ func TestHandleOCSP_MethodNotAllowed(t *testing.T) {
}
}
// === Phase-4 POST OCSP (RFC 6960 §A.1.1) Tests ===
// buildOCSPRequest constructs a binary DER-encoded OCSPRequest body
// for testing the POST handler. The same shape is what production
// clients (Firefox, OpenSSL, cert-manager) send.
func buildOCSPRequest(t *testing.T, serial *big.Int) []byte {
t.Helper()
// Build a minimal issuer cert + leaf cert pair so ocsp.CreateRequest
// has the SubjectPublicKeyInfo + serial it needs.
caKey, _ := rsa.GenerateKey(rand.Reader, 2048)
caTpl := &x509.Certificate{
SerialNumber: big.NewInt(0xCA),
Subject: pkix.Name{CommonName: "Test Issuer"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
BasicConstraintsValid: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create CA: %v", err)
}
caCert, _ := x509.ParseCertificate(caDER)
leafTpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "leaf.example.com"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
leafKey, _ := rsa.GenerateKey(rand.Reader, 2048)
leafDER, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, caKey)
if err != nil {
t.Fatalf("create leaf: %v", err)
}
leafCert, _ := x509.ParseCertificate(leafDER)
body, err := ocsp.CreateRequest(leafCert, caCert, &ocsp.RequestOptions{Hash: crypto.SHA256})
if err != nil {
t.Fatalf("create OCSP request: %v", err)
}
return body
}
func TestHandleOCSPPost_Success(t *testing.T) {
wantSerial := big.NewInt(0xDEADBEEF)
expectedHex := fmt.Sprintf("%x", wantSerial)
mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, issuerID string, serialHex string) ([]byte, error) {
if issuerID != "iss-local" {
return nil, fmt.Errorf("unexpected issuer %q", issuerID)
}
if serialHex != expectedHex {
return nil, fmt.Errorf("unexpected serial %q (want %q)", serialHex, expectedHex)
}
return []byte{0x30, 0x82, 0x02, 0x00}, nil
},
}
handler := NewCertificateHandler(mock)
body := buildOCSPRequest(t, wantSerial)
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/ocsp-request")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "application/ocsp-response" {
t.Errorf("Content-Type = %q, want application/ocsp-response", ct)
}
}
func TestHandleOCSPPost_RejectsNonPostMethod(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/.well-known/pki/ocsp/iss-local", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("got %d, want 405", w.Code)
}
}
func TestHandleOCSPPost_RejectsWrongContentType(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader([]byte("garbage")))
req.Header.Set("Content-Type", "text/plain")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusUnsupportedMediaType {
t.Errorf("got %d, want 415", w.Code)
}
}
func TestHandleOCSPPost_AcceptsMissingContentType(t *testing.T) {
// Real-world tolerance: some clients omit the header entirely.
// Validation falls through to ocsp.ParseRequest which will reject
// a non-OCSP body with a 400.
body := buildOCSPRequest(t, big.NewInt(1))
mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, _, _ string) ([]byte, error) {
return []byte{0x30, 0x82}, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader(body))
// Intentionally NOT setting Content-Type.
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusOK {
t.Errorf("got %d, want 200 with missing Content-Type (body=%s)", w.Code, w.Body.String())
}
}
func TestHandleOCSPPost_RejectsMalformedBody(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader([]byte("not-an-ocsp-request")))
req.Header.Set("Content-Type", "application/ocsp-request")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", w.Code)
}
}
func TestHandleOCSPPost_RejectsMissingIssuer(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
body := buildOCSPRequest(t, big.NewInt(1))
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/ocsp-request")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400", w.Code)
}
}
func TestHandleOCSPPost_PropagatesNotFound(t *testing.T) {
mock := &MockCertificateService{
GetOCSPResponseFn: func(_ context.Context, _, _ string) ([]byte, error) {
return nil, fmt.Errorf("certificate not found")
},
}
handler := NewCertificateHandler(mock)
body := buildOCSPRequest(t, big.NewInt(1))
req := httptest.NewRequest(http.MethodPost, "/.well-known/pki/ocsp/iss-local", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/ocsp-request")
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSPPost(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("got %d, want 404", w.Code)
}
}
// === M20 Enhanced Query API Tests ===
// TestListCertificates_SortParam tests sort parameter parsing and passing to service.
@@ -1315,9 +1491,9 @@ func TestListCertificates_CreatedAfterFilter(t *testing.T) {
// TestListCertificates_CursorPagination tests cursor-based pagination response.
func TestListCertificates_CursorPagination(t *testing.T) {
cert := domain.ManagedCertificate{
ID: "mc-cursor-test-1",
ID: "mc-cursor-test-1",
CommonName: "cursor.example.com",
CreatedAt: time.Now(),
CreatedAt: time.Now(),
}
mock := &MockCertificateService{
+91 -1
View File
@@ -1,15 +1,19 @@
package handler
import (
"errors"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
@@ -622,6 +626,92 @@ func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) {
w.Write(derBytes)
}
// HandleOCSPPost processes RFC 6960 §A.1.1 POST OCSP requests.
// POST /.well-known/pki/ocsp/{issuer_id}
//
// The body MUST be the binary DER-encoded OCSPRequest with content-type
// "application/ocsp-request". The response is the same DER-encoded
// OCSPResponse with content-type "application/ocsp-response" returned
// by the existing GET handler — only the input shape differs.
//
// POST is the standard transport for production OCSP clients (Firefox,
// OpenSSL `s_client -status`, cert-manager, Microsoft Intune device
// validators). The pre-existing GET form is kept for ad-hoc curl
// inspection + human-readable URL paths.
//
// Bundle CRL/OCSP-Responder Phase 4.
func (h CertificateHandler) HandleOCSPPost(w http.ResponseWriter, r *http.Request) {
requestID, _ := r.Context().Value("request_id").(string)
if r.Method != http.MethodPost {
ErrorWithRequestID(w, http.StatusMethodNotAllowed, "Method not allowed", requestID)
return
}
// Be tolerant about Content-Type: RFC 6960 §A.1.1 says it MUST be
// "application/ocsp-request" but real-world clients sometimes omit
// the header or send it with a charset suffix. We require the
// substring "ocsp-request" rather than exact match — the actual
// validation happens in ocsp.ParseRequest below; a malformed body
// fails there with a 400.
ct := r.Header.Get("Content-Type")
if ct != "" && !strings.Contains(strings.ToLower(ct), "ocsp-request") {
ErrorWithRequestID(w, http.StatusUnsupportedMediaType,
fmt.Sprintf("Content-Type must be application/ocsp-request, got %q", ct), requestID)
return
}
// Issuer ID from the path. The router pattern strips the leading
// /.well-known/pki/ocsp/ prefix; what remains is the bare issuer ID.
issuerID := strings.TrimPrefix(r.URL.Path, "/.well-known/pki/ocsp/")
issuerID = strings.TrimSuffix(issuerID, "/")
if issuerID == "" || strings.Contains(issuerID, "/") {
ErrorWithRequestID(w, http.StatusBadRequest, "Issuer ID is required", requestID)
return
}
// Body is already MaxBytesReader-capped by the body-size middleware.
// OCSPRequest bodies are tiny (~200 bytes for a single-cert query),
// so the default cap is comfortably above what any legitimate client
// will send.
body, err := io.ReadAll(r.Body)
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest, "Failed to read request body", requestID)
return
}
ocspReq, err := ocsp.ParseRequest(body)
if err != nil {
ErrorWithRequestID(w, http.StatusBadRequest,
fmt.Sprintf("Invalid OCSPRequest: %v", err), requestID)
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)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "not found") {
ErrorWithRequestID(w, http.StatusNotFound, errMsg, requestID)
return
}
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
ErrorWithRequestID(w, http.StatusNotImplemented, errMsg, requestID)
return
}
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to generate OCSP response", requestID)
return
}
w.Header().Set("Content-Type", "application/ocsp-response")
w.Header().Set("Cache-Control", "max-age=3600")
w.WriteHeader(http.StatusOK)
w.Write(derBytes)
}
// GetCertificateDeployments retrieves all deployment targets for a certificate.
// GET /api/v1/certificates/{id}/deployments
func (h CertificateHandler) GetCertificateDeployments(w http.ResponseWriter, r *http.Request) {
+14 -9
View File
@@ -66,10 +66,10 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter,
// The TestRouter_AuthExemptAllowlist regression test below pins the slice
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
var AuthExemptRouterRoutes = []string{
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
"GET /api/v1/version", // Rollout probes need build identity without key
}
// AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
@@ -81,9 +81,9 @@ var AuthExemptRouterRoutes = []string{
// TestDispatch_AuthExemptPrefixes regression test in cmd/server/main_test.go
// pins this slice to buildFinalHandler's actual dispatch logic.
var AuthExemptDispatchPrefixes = []string{
"/.well-known/pki", // RFC 5280 CRL + RFC 6960 OCSP — relying-party-unauth
"/.well-known/est", // RFC 7030 EST — auth via mTLS or CSR-embedded creds
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
"/.well-known/pki", // RFC 5280 CRL + RFC 6960 OCSP — relying-party-unauth
"/.well-known/est", // RFC 7030 EST — auth via mTLS or CSR-embedded creds
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
}
// HandlerRegistry groups all API handler dependencies for router registration.
@@ -108,8 +108,8 @@ type HandlerRegistry struct {
Verification handler.VerificationHandler
Export handler.ExportHandler
Digest handler.DigestHandler
HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler
HealthChecks *handler.HealthCheckHandler
BulkRevocation handler.BulkRevocationHandler
// L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
// loops in CertificatesPage.tsx. See handler/bulk_renewal.go and
@@ -392,6 +392,11 @@ func (r *Router) RegisterSCEPHandlers(scep handler.SCEPHandler) {
func (r *Router) RegisterPKIHandlers(pki handler.CertificateHandler) {
r.Register("GET /.well-known/pki/crl/{issuer_id}", http.HandlerFunc(pki.GetDERCRL))
r.Register("GET /.well-known/pki/ocsp/{issuer_id}/{serial}", http.HandlerFunc(pki.HandleOCSP))
// RFC 6960 §A.1.1 standard POST form. The binary OCSPRequest body
// carries the serial; the URL only needs the issuer ID. Most
// production OCSP clients use POST exclusively (see CRL/OCSP-Responder
// Phase 4 prompt for the full client compatibility matrix).
r.Register("POST /.well-known/pki/ocsp/{issuer_id}", http.HandlerFunc(pki.HandleOCSPPost))
}
// GetMux returns the underlying http.ServeMux for direct access if needed.
+40 -10
View File
@@ -12,14 +12,19 @@ import (
// CertificateService provides business logic for certificate management.
type CertificateService struct {
certRepo repository.CertificateRepository
targetRepo repository.TargetRepository
jobRepo repository.JobRepository
policyService *PolicyService
auditService *AuditService
revSvc *RevocationSvc
caSvc *CAOperationsSvc
keygenMode string
certRepo repository.CertificateRepository
targetRepo repository.TargetRepository
jobRepo repository.JobRepository
policyService *PolicyService
auditService *AuditService
revSvc *RevocationSvc
caSvc *CAOperationsSvc
// crlCacheSvc, when set, makes GenerateDERCRL serve from the
// pre-generated cache instead of regenerating per request. Bundle
// CRL/OCSP-Responder Phase 4. Optional; when nil GenerateDERCRL
// falls back to the historical on-demand path via caSvc.
crlCacheSvc *CRLCacheService
keygenMode string
}
// NewCertificateService creates a new certificate service.
@@ -45,6 +50,17 @@ func (s *CertificateService) SetCAOperationsSvc(svc *CAOperationsSvc) {
s.caSvc = svc
}
// SetCRLCacheSvc wires the CRL cache service. When set, GenerateDERCRL
// reads from the scheduler-pre-generated cache (cheap DB lookup) and
// only triggers an on-demand regeneration on cache miss / staleness.
// When unset, GenerateDERCRL falls back to the historical per-request
// regeneration via caSvc.
//
// Bundle CRL/OCSP-Responder Phase 4.
func (s *CertificateService) SetCRLCacheSvc(svc *CRLCacheService) {
s.crlCacheSvc = svc
}
// SetTargetRepo sets the target repository for deployment queries.
func (s *CertificateService) SetTargetRepo(repo repository.TargetRepository) {
s.targetRepo = repo
@@ -481,9 +497,23 @@ func (s *CertificateService) GetRevokedCertificates(ctx context.Context) ([]*dom
return s.revSvc.GetRevokedCertificates(ctx)
}
// GenerateDERCRL generates a DER-encoded X.509 CRL for the given issuer.
// Delegates to CAOperationsSvc.
// GenerateDERCRL returns the DER-encoded X.509 CRL for the given
// issuer. When the CRL cache service is wired (SetCRLCacheSvc), reads
// from the scheduler-pre-generated cache and only regenerates on miss
// / staleness — the cache layer's singleflight gate collapses
// concurrent miss requests to a single underlying generation.
//
// When the cache service is not wired, falls back to the historical
// on-demand path via CAOperationsSvc.GenerateDERCRL — every HTTP fetch
// triggers a fresh generation.
//
// Backward-compatible: existing callers that don't wire the cache see
// no behavioural change.
func (s *CertificateService) GenerateDERCRL(ctx context.Context, issuerID string) ([]byte, error) {
if s.crlCacheSvc != nil {
der, _, err := s.crlCacheSvc.Get(ctx, issuerID)
return der, err
}
if s.caSvc == nil {
return nil, fmt.Errorf("CA operations service not configured")
}