mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21: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:
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user