mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:51:31 +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:
@@ -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{
|
||||
|
||||
@@ -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