mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 08:08:51 +00:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user