diff --git a/api/openapi.yaml b/api/openapi.yaml index e18aa98..e8c4a1c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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: diff --git a/internal/api/handler/certificate_handler_test.go b/internal/api/handler/certificate_handler_test.go index 000beab..2a83f2b 100644 --- a/internal/api/handler/certificate_handler_test.go +++ b/internal/api/handler/certificate_handler_test.go @@ -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{ diff --git a/internal/api/handler/certificates.go b/internal/api/handler/certificates.go index 550d2a4..a9fd449 100644 --- a/internal/api/handler/certificates.go +++ b/internal/api/handler/certificates.go @@ -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) { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 3802bd5..477d45c 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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. diff --git a/internal/service/certificate.go b/internal/service/certificate.go index b8c6db2..afacd9a 100644 --- a/internal/service/certificate.go +++ b/internal/service/certificate.go @@ -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") }