feat: M15b — OCSP responder, DER CRL, short-lived exemption, revocation GUI

Backend:
- Embedded OCSP responder: GET /api/v1/ocsp/{issuer_id}/{serial} returns
  signed OCSP responses (good/revoked/unknown) using CA key
- DER-encoded X.509 CRL: GET /api/v1/crl/{issuer_id} returns proper DER CRL
  signed by issuing CA with 24h validity window
- Short-lived cert exemption: certs with profile TTL < 1 hour skip CRL/OCSP
  (expiry is sufficient revocation for ephemeral workloads)
- Extended issuer connector interface with GenerateCRL and SignOCSPResponse
- Local CA implements full CRL/OCSP signing; ACME and step-ca return
  appropriate "use native endpoint" errors
- IssuerConnectorAdapter bridges new methods between layers

Frontend:
- Revoke button on certificate detail page with RFC 5280 reason modal
- Revocation banner with reason display and timestamp
- Revocation status indicators in lifecycle section
- "Revoked" filter option in certificates list
- API client: revokeCertificate() function and Certificate type extensions

Tests: ~31 new tests across connector, service, handler, and adapter layers
Docs: milestones renumbered (M13-M14, M16-M18), M15b marked complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-22 14:39:10 -04:00
parent c2cd48c38b
commit 458a8c2740
22 changed files with 1470 additions and 15 deletions
@@ -26,6 +26,8 @@ type MockCertificateService struct {
TriggerDeploymentFn func(certID string, targetID string) error
RevokeCertificateFn func(certID string, reason string) error
GetRevokedCertificatesFn func() ([]*domain.CertificateRevocation, error)
GenerateDERCRLFn func(issuerID string) ([]byte, error)
GetOCSPResponseFn func(issuerID string, serialHex string) ([]byte, error)
}
func (m *MockCertificateService) ListCertificates(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
@@ -98,6 +100,20 @@ func (m *MockCertificateService) GetRevokedCertificates() ([]*domain.Certificate
return nil, nil
}
func (m *MockCertificateService) GenerateDERCRL(issuerID string) ([]byte, error) {
if m.GenerateDERCRLFn != nil {
return m.GenerateDERCRLFn(issuerID)
}
return nil, nil
}
func (m *MockCertificateService) GetOCSPResponse(issuerID string, serialHex string) ([]byte, error) {
if m.GetOCSPResponseFn != nil {
return m.GetOCSPResponseFn(issuerID, serialHex)
}
return nil, nil
}
// Helper function to create context with request ID.
func contextWithRequestID() context.Context {
return context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-123")
@@ -1042,3 +1058,181 @@ func TestGetCRL_MethodNotAllowed(t *testing.T) {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// M15b: DER CRL and OCSP Handler Tests
func TestGetDERCRL_Success(t *testing.T) {
derCRLData := []byte{0x30, 0x82, 0x01, 0x00} // Mock DER CRL bytes
mock := &MockCertificateService{
GenerateDERCRLFn: func(issuerID string) ([]byte, error) {
if issuerID == "iss-local" {
return derCRLData, nil
}
return nil, fmt.Errorf("issuer not found")
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/issuers/iss-local/crl", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetDERCRL(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
// Verify response is DER data
responseBody := w.Body.Bytes()
if len(responseBody) == 0 {
t.Error("expected non-empty response body")
}
}
func TestGetDERCRL_IssuerNotFound(t *testing.T) {
mock := &MockCertificateService{
GenerateDERCRLFn: func(issuerID string) ([]byte, error) {
return nil, fmt.Errorf("issuer not found")
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/issuers/nonexistent/crl", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetDERCRL(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
func TestGetDERCRL_NotSupported(t *testing.T) {
mock := &MockCertificateService{
GenerateDERCRLFn: func(issuerID string) ([]byte, error) {
return nil, fmt.Errorf("issuer does not support CRL generation")
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/issuers/iss-acme/crl", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetDERCRL(w, req)
// Service should return an error; handler routes to appropriate status
if w.Code == http.StatusOK {
t.Errorf("expected error status, got %d", w.Code)
}
}
func TestGetDERCRL_MethodNotAllowed(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-local/crl", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetDERCRL(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
func TestHandleOCSP_Success(t *testing.T) {
ocspResponseBytes := []byte{0x30, 0x82, 0x02, 0x00} // Mock OCSP response
mock := &MockCertificateService{
GetOCSPResponseFn: func(issuerID string, serialHex string) ([]byte, error) {
if issuerID == "iss-local" && serialHex == "12345" {
return ocspResponseBytes, nil
}
return nil, fmt.Errorf("certificate not found")
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/issuers/iss-local/ocsp?serial=12345", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, w.Code)
}
responseBody := w.Body.Bytes()
if len(responseBody) == 0 {
t.Error("expected non-empty OCSP response body")
}
}
func TestHandleOCSP_MissingSerial(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/issuers/iss-local/ocsp", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
func TestHandleOCSP_IssuerNotFound(t *testing.T) {
mock := &MockCertificateService{
GetOCSPResponseFn: func(issuerID string, serialHex string) ([]byte, error) {
return nil, fmt.Errorf("issuer not found")
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/issuers/nonexistent/ocsp?serial=ABC123", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
func TestHandleOCSP_CertNotFound(t *testing.T) {
mock := &MockCertificateService{
GetOCSPResponseFn: func(issuerID string, serialHex string) ([]byte, error) {
return nil, fmt.Errorf("certificate not found")
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/issuers/iss-local/ocsp?serial=UNKNOWN", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code)
}
}
func TestHandleOCSP_MethodNotAllowed(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-local/ocsp?serial=12345", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.HandleOCSP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
+77
View File
@@ -23,6 +23,8 @@ type CertificateService interface {
TriggerDeployment(certID string, targetID string) error
RevokeCertificate(certID string, reason string) error
GetRevokedCertificates() ([]*domain.CertificateRevocation, error)
GenerateDERCRL(issuerID string) ([]byte, error)
GetOCSPResponse(issuerID string, serialHex string) ([]byte, error)
}
// CertificateHandler handles HTTP requests for certificate operations.
@@ -444,3 +446,78 @@ func (h CertificateHandler) GetCRL(w http.ResponseWriter, r *http.Request) {
"generated_at": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
})
}
// GetDERCRL returns a DER-encoded X.509 CRL signed by the specified issuer.
// GET /api/v1/crl/{issuer_id}
func (h CertificateHandler) GetDERCRL(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
issuerID := strings.TrimPrefix(r.URL.Path, "/api/v1/crl/")
if issuerID == "" {
Error(w, http.StatusBadRequest, "Issuer ID is required")
return
}
derBytes, err := h.svc.GenerateDERCRL(issuerID)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "issuer not found") {
Error(w, http.StatusNotFound, errMsg)
return
}
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
Error(w, http.StatusNotImplemented, errMsg)
return
}
Error(w, http.StatusInternalServerError, "Failed to generate CRL")
return
}
w.Header().Set("Content-Type", "application/pkix-crl")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.WriteHeader(http.StatusOK)
w.Write(derBytes)
}
// HandleOCSP processes OCSP requests.
// GET /api/v1/ocsp/{issuer_id}/{serial_hex}
// For simplicity, use GET with path params instead of binary POST.
func (h CertificateHandler) HandleOCSP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
// Extract issuer_id and serial from path: /api/v1/ocsp/{issuer_id}/{serial_hex}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/ocsp/")
parts := strings.SplitN(path, "/", 2)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
Error(w, http.StatusBadRequest, "Issuer ID and serial number are required")
return
}
issuerID := parts[0]
serialHex := parts[1]
derBytes, err := h.svc.GetOCSPResponse(issuerID, serialHex)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "issuer not found") {
Error(w, http.StatusNotFound, errMsg)
return
}
if strings.Contains(errMsg, "do not support") || strings.Contains(errMsg, "does not support") {
Error(w, http.StatusNotImplemented, errMsg)
return
}
Error(w, http.StatusInternalServerError, "Failed to generate OCSP response")
return
}
w.Header().Set("Content-Type", "application/ocsp-response")
w.Header().Set("Cache-Control", "max-age=3600")
w.WriteHeader(http.StatusOK)
w.Write(derBytes)
}