mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:41: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":
|
"501":
|
||||||
description: Issuer does not support OCSP
|
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 ─────────────────────────────────────────────────────────
|
# ─── Issuers ─────────────────────────────────────────────────────────
|
||||||
/api/v1/issuers:
|
/api/v1/issuers:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -3,13 +3,21 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ocsp"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"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 ===
|
// === M20 Enhanced Query API Tests ===
|
||||||
|
|
||||||
// TestListCertificates_SortParam tests sort parameter parsing and passing to service.
|
// 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.
|
// TestListCertificates_CursorPagination tests cursor-based pagination response.
|
||||||
func TestListCertificates_CursorPagination(t *testing.T) {
|
func TestListCertificates_CursorPagination(t *testing.T) {
|
||||||
cert := domain.ManagedCertificate{
|
cert := domain.ManagedCertificate{
|
||||||
ID: "mc-cursor-test-1",
|
ID: "mc-cursor-test-1",
|
||||||
CommonName: "cursor.example.com",
|
CommonName: "cursor.example.com",
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
mock := &MockCertificateService{
|
mock := &MockCertificateService{
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ocsp"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/api/middleware"
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
||||||
"github.com/shankar0123/certctl/internal/domain"
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
"github.com/shankar0123/certctl/internal/repository"
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
@@ -622,6 +626,92 @@ func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(derBytes)
|
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.
|
// GetCertificateDeployments retrieves all deployment targets for a certificate.
|
||||||
// GET /api/v1/certificates/{id}/deployments
|
// GET /api/v1/certificates/{id}/deployments
|
||||||
func (h CertificateHandler) GetCertificateDeployments(w http.ResponseWriter, r *http.Request) {
|
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
|
// The TestRouter_AuthExemptAllowlist regression test below pins the slice
|
||||||
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
|
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
|
||||||
var AuthExemptRouterRoutes = []string{
|
var AuthExemptRouterRoutes = []string{
|
||||||
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
|
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
|
||||||
"GET /ready", // K8s/Docker readiness 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/auth/info", // GUI calls before login to detect auth mode
|
||||||
"GET /api/v1/version", // Rollout probes need build identity without key
|
"GET /api/v1/version", // Rollout probes need build identity without key
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
|
// 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
|
// TestDispatch_AuthExemptPrefixes regression test in cmd/server/main_test.go
|
||||||
// pins this slice to buildFinalHandler's actual dispatch logic.
|
// pins this slice to buildFinalHandler's actual dispatch logic.
|
||||||
var AuthExemptDispatchPrefixes = []string{
|
var AuthExemptDispatchPrefixes = []string{
|
||||||
"/.well-known/pki", // RFC 5280 CRL + RFC 6960 OCSP — relying-party-unauth
|
"/.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
|
"/.well-known/est", // RFC 7030 EST — auth via mTLS or CSR-embedded creds
|
||||||
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
|
"/scep", // RFC 8894 SCEP — auth via challengePassword in CSR
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandlerRegistry groups all API handler dependencies for router registration.
|
// HandlerRegistry groups all API handler dependencies for router registration.
|
||||||
@@ -108,8 +108,8 @@ type HandlerRegistry struct {
|
|||||||
Verification handler.VerificationHandler
|
Verification handler.VerificationHandler
|
||||||
Export handler.ExportHandler
|
Export handler.ExportHandler
|
||||||
Digest handler.DigestHandler
|
Digest handler.DigestHandler
|
||||||
HealthChecks *handler.HealthCheckHandler
|
HealthChecks *handler.HealthCheckHandler
|
||||||
BulkRevocation handler.BulkRevocationHandler
|
BulkRevocation handler.BulkRevocationHandler
|
||||||
// L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
|
// L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
|
||||||
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
|
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
|
||||||
// loops in CertificatesPage.tsx. See handler/bulk_renewal.go and
|
// 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) {
|
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/crl/{issuer_id}", http.HandlerFunc(pki.GetDERCRL))
|
||||||
r.Register("GET /.well-known/pki/ocsp/{issuer_id}/{serial}", http.HandlerFunc(pki.HandleOCSP))
|
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.
|
// GetMux returns the underlying http.ServeMux for direct access if needed.
|
||||||
|
|||||||
@@ -12,14 +12,19 @@ import (
|
|||||||
|
|
||||||
// CertificateService provides business logic for certificate management.
|
// CertificateService provides business logic for certificate management.
|
||||||
type CertificateService struct {
|
type CertificateService struct {
|
||||||
certRepo repository.CertificateRepository
|
certRepo repository.CertificateRepository
|
||||||
targetRepo repository.TargetRepository
|
targetRepo repository.TargetRepository
|
||||||
jobRepo repository.JobRepository
|
jobRepo repository.JobRepository
|
||||||
policyService *PolicyService
|
policyService *PolicyService
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
revSvc *RevocationSvc
|
revSvc *RevocationSvc
|
||||||
caSvc *CAOperationsSvc
|
caSvc *CAOperationsSvc
|
||||||
keygenMode string
|
// 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.
|
// NewCertificateService creates a new certificate service.
|
||||||
@@ -45,6 +50,17 @@ func (s *CertificateService) SetCAOperationsSvc(svc *CAOperationsSvc) {
|
|||||||
s.caSvc = svc
|
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.
|
// SetTargetRepo sets the target repository for deployment queries.
|
||||||
func (s *CertificateService) SetTargetRepo(repo repository.TargetRepository) {
|
func (s *CertificateService) SetTargetRepo(repo repository.TargetRepository) {
|
||||||
s.targetRepo = repo
|
s.targetRepo = repo
|
||||||
@@ -481,9 +497,23 @@ func (s *CertificateService) GetRevokedCertificates(ctx context.Context) ([]*dom
|
|||||||
return s.revSvc.GetRevokedCertificates(ctx)
|
return s.revSvc.GetRevokedCertificates(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateDERCRL generates a DER-encoded X.509 CRL for the given issuer.
|
// GenerateDERCRL returns the DER-encoded X.509 CRL for the given
|
||||||
// Delegates to CAOperationsSvc.
|
// 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) {
|
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 {
|
if s.caSvc == nil {
|
||||||
return nil, fmt.Errorf("CA operations service not configured")
|
return nil, fmt.Errorf("CA operations service not configured")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user