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:
shankar0123
2026-03-22 14:39:10 -04:00
parent 12e6150219
commit 762c523d59
22 changed files with 1470 additions and 15 deletions
+7 -5
View File
@@ -10,7 +10,7 @@ certctl is a self-hosted platform for **end-to-end certificate lifecycle automat
## What It Does
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (70 endpoints) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally and submit CSRs — private keys never leave your servers. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
certctl gives you a single pane of glass for every TLS certificate in your organization. The **web dashboard** shows your full certificate inventory — what's healthy, what's expiring, what's already expired, and who owns each one. The **REST API** (72 endpoints) lets you automate everything. **Agents** deployed on your infrastructure generate private keys locally and submit CSRs — private keys never leave your servers. The background scheduler watches expiration dates and triggers renewals automatically — when certificate lifespans drop to 47 days, certctl handles the constant rotation without human involvement.
```mermaid
flowchart LR
@@ -211,6 +211,8 @@ POST /api/v1/certificates/{id}/renew Trigger renewal → 202 Accepted
POST /api/v1/certificates/{id}/deploy Trigger deployment → 202 Accepted
POST /api/v1/certificates/{id}/revoke Revoke with RFC 5280 reason code
GET /api/v1/crl Certificate Revocation List (JSON)
GET /api/v1/crl/{issuer_id} DER-encoded X.509 CRL
GET /api/v1/ocsp/{issuer_id}/{serial} OCSP responder (good/revoked/unknown)
```
### Agents
@@ -369,12 +371,12 @@ All nine development milestones (M1M9) are complete. The backend covers the f
- **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors
- **M11: Crypto Policy + Profiles + Ownership** ✅ — certificate profiles (named enrollment profiles with allowed key types, max TTL, crypto constraints), certificate ownership tracking (owners + teams + notification routing), dynamic agent groups (OS/arch/IP CIDR/version matching), interactive renewal approval (AwaitingApproval state)
- **M12: Sub-CA + DNS-01 + step-ca** ✅ — Local CA sub-CA mode (enterprise root chain with RSA/ECDSA/PKCS#8), ACME DNS-01 challenges (script-based DNS hooks for any provider, wildcard cert support), step-ca issuer connector (native /sign API with JWK provisioner auth)
- **M13: GUI Operations** — bulk cert operations (renew, revoke, reassign), deployment timeline, inline policy editor, target config wizard, audit export, short-lived credentials dashboard
- **M14: Additional Connectors** — OpenSSL/Custom CA issuer connector
- **M15a: Core Revocation** ✅ — revocation API with all RFC 5280 reason codes, JSON CRL endpoint, webhook + email revocation notifications, best-effort issuer notification, `certificate_revocations` table with idempotent recording, 48 new tests
- **M15b: OCSP + Revocation GUI** — embedded OCSP responder, DER-encoded X.509 CRL, short-lived cert exemption, revocation GUI
- **M15b: OCSP + Revocation GUI** — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests
- **M13: GUI Operations** — bulk cert operations (renew, revoke, reassign), deployment timeline, inline policy editor, target config wizard, audit export, short-lived credentials dashboard
- **M14: Observability** — expiration calendar/heatmap, Prometheus metrics endpoint, structured logging improvements, deployment rollback
- **M16: Operator Tooling** — CLI tool (`certctl`), Slack/Teams/PagerDuty/OpsGenie notifiers, bulk certificate import
- **M17: Observability** — expiration calendar/heatmap, Prometheus metrics endpoint, structured logging improvements, deployment rollback
- **M17: Additional Connectors** — OpenSSL/Custom CA issuer connector
- **M18: Integrations** — MCP server (OpenClaw/Claude/Cursor), filesystem cert discovery
### V3: Team & Enterprise
+1
View File
@@ -138,6 +138,7 @@ func main() {
certificateService.SetRevocationRepo(revocationRepo)
certificateService.SetNotificationService(notificationService)
certificateService.SetIssuerRegistry(issuerRegistry)
certificateService.SetProfileRepo(profileRepo)
renewalService := service.NewRenewalService(certificateRepo, jobRepo, renewalPolicyRepo, profileRepo, auditService, notificationService, issuerRegistry, cfg.Keygen.Mode)
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certificateRepo, auditService, notificationService)
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
+4 -4
View File
@@ -617,13 +617,13 @@ For production, you would also add an ingress controller, TLS termination for th
## Testing Strategy
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 600+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 630+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database.
**Service layer unit tests** (`internal/service/*_test.go`) — 207 test functions across 15 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, certificate revocation (success, already-revoked, archived, invalid reason, all RFC 5280 reason codes, issuer notification, notification service integration), agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers including revocation). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
**Service layer unit tests** (`internal/service/*_test.go`) — ~238 test functions across 15 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, certificate revocation (success, already-revoked, archived, invalid reason, all RFC 5280 reason codes, issuer notification, notification service integration, OCSP/CRL generation), agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers including revocation). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs.
**Handler layer tests** (`internal/api/handler/*_test.go`) — 226 test functions across 11 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (36 tests including revocation and CRL), agents (28 tests), jobs (21 tests including approve/reject), notifications (11 tests), policies (19 tests), profiles (18 tests), issuers (17 tests), targets (17 tests), agent groups (12 tests), teams (26 tests), and owners (21 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs, name length limits), error propagation from the service layer, method-not-allowed responses, and pagination parameters.
**Handler layer tests** (`internal/api/handler/*_test.go`) — ~257 test functions across 11 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (50 tests including revocation, DER CRL, and OCSP), agents (28 tests), jobs (21 tests including approve/reject), notifications (11 tests), policies (19 tests), profiles (18 tests), issuers (17 tests), targets (17 tests), agent groups (12 tests), teams (26 tests), and owners (21 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs, name length limits), error propagation from the service layer, method-not-allowed responses, and pagination parameters.
**Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths, 19 M11b endpoint tests, and 4 revocation endpoint tests: nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, expired certificate lifecycle, team/owner/agent group CRUD validation, revocation success, already-revoked rejection, not-found revocation, and CRL retrieval. Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector.
**Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths, 19 M11b endpoint tests, and 8 revocation endpoint tests (M15a+M15b): nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, expired certificate lifecycle, team/owner/agent group CRUD validation, revocation success, already-revoked rejection, not-found revocation, JSON CRL retrieval, DER CRL retrieval, OCSP response retrieval, and short-lived cert exemption. Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector.
**Frontend tests** (`web/src/api/client.test.ts`, `web/src/api/utils.test.ts`) — 53 Vitest tests covering the API client and utility functions. The API client tests mock `globalThis.fetch` and verify all endpoint functions (certificates, agents, jobs, policies, issuers, targets, notifications, audit, health) send correct HTTP methods, URLs, headers, and request bodies. They also test API key management (store/retrieve/clear), auth header propagation, 401 event dispatching, and error handling (server messages, error fields, status text fallback). The utility tests use `vi.useFakeTimers()` for deterministic date testing and cover `formatDate`, `formatDateTime`, `timeAgo`, `daysUntil`, and `expiryColor`. The test environment uses jsdom with `@testing-library/jest-dom` matchers.
+6
View File
@@ -87,6 +87,8 @@ The Local CA issuer signs certificates using Go's `crypto/x509` library. It supp
**Sub-CA mode:** Loads a CA certificate and private key from disk (`CERTCTL_CA_CERT_PATH` + `CERTCTL_CA_KEY_PATH`). The CA cert is signed by an upstream CA (e.g., ADCS), so all issued certificates chain to the enterprise root trust hierarchy. Clients that already trust the enterprise root automatically trust certctl-issued certs. Supports RSA, ECDSA, and PKCS#8 key formats. If the paths are not set, falls back to self-signed mode. The loaded certificate must have `IsCA=true` and `KeyUsageCertSign`.
**CRL and OCSP support (M15b):** The Local CA supports DER-encoded X.509 CRL generation via `GET /api/v1/crl/{issuer_id}` with 24-hour validity. An embedded OCSP responder at `GET /api/v1/ocsp/{issuer_id}/{serial}` returns signed OCSP responses for issued certificates (good/revoked/unknown status). Certificates with profile TTL < 1 hour automatically skip CRL/OCSP — expiry is treated as sufficient revocation for short-lived credentials.
Configuration:
```json
{
@@ -139,6 +141,8 @@ Environment variables for the default ACME connector:
The connector is registered in the issuer registry under `iss-acme-staging` and `iss-acme-prod`. Use `iss-acme-staging` for Let's Encrypt staging (rate-limit-friendly testing) and `iss-acme-prod` for production certificates.
**Note:** ACME-issued certificates rely on the Local CA for CRL/OCSP endpoints if they are stored in certctl's inventory. For issuers with their own public CRL/OCSP infrastructure (e.g., Let's Encrypt), clients should validate against the issuer's endpoints instead.
Location: `internal/connector/issuer/acme/acme.go`, `internal/connector/issuer/acme/dns.go`
### Built-in: step-ca (Smallstep Private CA)
@@ -165,6 +169,8 @@ Environment variables:
The connector is registered in the issuer registry under `iss-stepca`. step-ca also works with the existing ACME connector (point `iss-acme-*` at step-ca's ACME directory URL for ACME-based issuance).
**Note:** step-ca-issued certificates rely on step-ca's own CRL/OCSP infrastructure. certctl's local CRL/OCSP endpoints (`GET /api/v1/crl/{issuer_id}` and `GET /api/v1/ocsp/{issuer_id}/{serial}`) are populated from step-ca's revocation data if available, but clients should validate against step-ca's endpoints for the authoritative status.
Location: `internal/connector/issuer/stepca/stepca.go`
### Planned Issuers
@@ -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)
}
+5 -1
View File
@@ -90,8 +90,12 @@ func (r *Router) RegisterHandlers(
r.Register("POST /api/v1/certificates/{id}/deploy", http.HandlerFunc(certificates.TriggerDeployment))
r.Register("POST /api/v1/certificates/{id}/revoke", http.HandlerFunc(certificates.RevokeCertificate))
// CRL endpoint: /api/v1/crl
// CRL endpoints: /api/v1/crl (JSON) and /api/v1/crl/{issuer_id} (DER)
r.Register("GET /api/v1/crl", http.HandlerFunc(certificates.GetCRL))
r.Register("GET /api/v1/crl/{issuer_id}", http.HandlerFunc(certificates.GetDERCRL))
// OCSP responder: /api/v1/ocsp/{issuer_id}/{serial}
r.Register("GET /api/v1/ocsp/{issuer_id}/{serial}", http.HandlerFunc(certificates.HandleOCSP))
// Issuers routes: /api/v1/issuers
r.Register("GET /api/v1/issuers", http.HandlerFunc(issuers.ListIssuers))
+10
View File
@@ -609,3 +609,13 @@ func parseDERChain(derChain [][]byte) (certPEM string, chainPEM string, serial s
return
}
// GenerateCRL is not supported by ACME issuers.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("ACME issuers do not support CRL generation")
}
// SignOCSPResponse is not supported by ACME issuers.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("ACME issuers do not support OCSP response signing")
}
+26
View File
@@ -3,6 +3,7 @@ package issuer
import (
"context"
"encoding/json"
"math/big"
"time"
)
@@ -22,6 +23,14 @@ type Connector interface {
// GetOrderStatus retrieves the status of an issuance or renewal order.
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
// GenerateCRL generates a DER-encoded X.509 CRL signed by this issuer.
// Returns nil if the issuer does not support CRL generation (e.g., ACME).
GenerateCRL(ctx context.Context, revokedCerts []RevokedCertEntry) ([]byte, error)
// SignOCSPResponse signs an OCSP response for the given certificate serial.
// Returns nil if the issuer does not support OCSP (e.g., ACME).
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
}
// IssuanceRequest contains the parameters for issuing a new certificate.
@@ -67,3 +76,20 @@ type OrderStatus struct {
NotAfter *time.Time `json:"not_after,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// RevokedCertEntry represents a revoked certificate for CRL generation.
type RevokedCertEntry struct {
SerialNumber *big.Int
RevokedAt time.Time
ReasonCode int
}
// OCSPSignRequest contains the parameters for signing an OCSP response.
type OCSPSignRequest struct {
CertSerial *big.Int
CertStatus int // 0=good, 1=revoked, 2=unknown
RevokedAt time.Time
RevocationReason int
ThisUpdate time.Time
NextUpdate time.Time
}
+75
View File
@@ -19,6 +19,8 @@ import (
"sync"
"time"
"golang.org/x/crypto/ocsp"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
@@ -582,3 +584,76 @@ func hashPublicKey(pub interface{}) []byte {
}
return h.Sum(nil)[:4] // Use first 4 bytes for brevity
}
// GenerateCRL generates a DER-encoded X.509 CRL signed by this local CA.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
if err := c.ensureCA(ctx); err != nil {
return nil, fmt.Errorf("CA initialization failed: %w", err)
}
now := time.Now()
revokedEntries := make([]x509.RevocationListEntry, 0, len(revokedCerts))
for _, cert := range revokedCerts {
revokedEntries = append(revokedEntries, x509.RevocationListEntry{
SerialNumber: cert.SerialNumber,
RevocationTime: cert.RevokedAt,
ReasonCode: cert.ReasonCode,
})
}
template := &x509.RevocationList{
RevokedCertificateEntries: revokedEntries,
Number: big.NewInt(time.Now().Unix()),
ThisUpdate: now,
NextUpdate: now.Add(24 * time.Hour),
}
crlBytes, err := x509.CreateRevocationList(rand.Reader, template, c.caCert, c.caKey)
if err != nil {
return nil, fmt.Errorf("failed to create CRL: %w", err)
}
c.logger.Info("CRL generated",
"entries", len(revokedCerts),
"next_update", template.NextUpdate)
return crlBytes, nil
}
// SignOCSPResponse signs an OCSP response for the given certificate.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
if err := c.ensureCA(ctx); err != nil {
return nil, fmt.Errorf("CA initialization failed: %w", err)
}
// Import OCSP after we confirm golang.org/x/crypto is available
// This will be added to imports below
template := ocsp.Response{
SerialNumber: req.CertSerial,
ThisUpdate: req.ThisUpdate,
NextUpdate: req.NextUpdate,
Certificate: c.caCert,
}
switch req.CertStatus {
case 0: // good
template.Status = ocsp.Good
case 1: // revoked
template.Status = ocsp.Revoked
template.RevokedAt = req.RevokedAt
template.RevocationReason = req.RevocationReason
default: // unknown
template.Status = ocsp.Unknown
}
respBytes, err := ocsp.CreateResponse(c.caCert, c.caCert, template, c.caKey)
if err != nil {
return nil, fmt.Errorf("failed to create OCSP response: %w", err)
}
c.logger.Info("OCSP response signed",
"serial", req.CertSerial,
"status", req.CertStatus)
return respBytes, nil
}
@@ -542,3 +542,364 @@ func generateTestCSR(commonName string) (*x509.CertificateRequest, string, error
return csr, string(csrPEM), nil
}
// M15b: CRL and OCSP Tests
func TestGenerateCRL_Empty(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
// Generate CRL with no revoked certs — should succeed with 0 entries
crl, err := connector.GenerateCRL(ctx, nil)
if err != nil {
t.Fatalf("GenerateCRL failed: %v", err)
}
if crl == nil {
t.Fatal("CRL is nil")
}
// Verify it's valid DER by parsing
parsedCRL, err := x509.ParseRevocationList(crl)
if err != nil {
t.Fatalf("failed to parse CRL: %v", err)
}
if len(parsedCRL.RevokedCertificateEntries) != 0 {
t.Errorf("expected 0 revoked entries, got %d", len(parsedCRL.RevokedCertificateEntries))
}
t.Logf("Empty CRL generated successfully with %d entries", len(parsedCRL.RevokedCertificateEntries))
}
func TestGenerateCRL_WithEntries(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
// Generate CRL with 2 revoked certs
entries := []issuer.RevokedCertEntry{
{SerialNumber: big.NewInt(12345), RevokedAt: time.Now().Add(-24 * time.Hour), ReasonCode: 1},
{SerialNumber: big.NewInt(67890), RevokedAt: time.Now().Add(-1 * time.Hour), ReasonCode: 4},
}
crl, err := connector.GenerateCRL(ctx, entries)
if err != nil {
t.Fatalf("GenerateCRL failed: %v", err)
}
if crl == nil {
t.Fatal("CRL is nil")
}
parsedCRL, err := x509.ParseRevocationList(crl)
if err != nil {
t.Fatalf("failed to parse CRL: %v", err)
}
if len(parsedCRL.RevokedCertificateEntries) != 2 {
t.Errorf("expected 2 revoked entries, got %d", len(parsedCRL.RevokedCertificateEntries))
}
// Verify entries contain expected serials
serials := make(map[string]bool)
for _, entry := range parsedCRL.RevokedCertificateEntries {
serials[entry.SerialNumber.String()] = true
}
if !serials["12345"] {
t.Error("expected serial 12345 in CRL")
}
if !serials["67890"] {
t.Error("expected serial 67890 in CRL")
}
t.Logf("CRL with entries generated successfully: %d entries", len(parsedCRL.RevokedCertificateEntries))
}
func TestGenerateCRL_BeforeCAInit(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// CRL generation should init the CA automatically
cfg := &local.Config{ValidityDays: 90}
connector := local.New(cfg, logger)
crl, err := connector.GenerateCRL(ctx, nil)
if err != nil {
t.Fatalf("GenerateCRL failed: %v", err)
}
if crl == nil {
t.Fatal("CRL is nil")
}
// Verify it's valid
_, err = x509.ParseRevocationList(crl)
if err != nil {
t.Fatalf("failed to parse CRL: %v", err)
}
t.Log("CRL generated with auto-initialized CA")
}
func TestGenerateCRL_WithReasonCodes(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
// Test all RFC 5280 reason codes
entries := []issuer.RevokedCertEntry{
{SerialNumber: big.NewInt(100), RevokedAt: time.Now(), ReasonCode: 0}, // unspecified
{SerialNumber: big.NewInt(101), RevokedAt: time.Now(), ReasonCode: 1}, // keyCompromise
{SerialNumber: big.NewInt(102), RevokedAt: time.Now(), ReasonCode: 2}, // caCompromise
{SerialNumber: big.NewInt(103), RevokedAt: time.Now(), ReasonCode: 3}, // affiliationChanged
{SerialNumber: big.NewInt(104), RevokedAt: time.Now(), ReasonCode: 4}, // superseded
}
crl, err := connector.GenerateCRL(ctx, entries)
if err != nil {
t.Fatalf("GenerateCRL failed: %v", err)
}
parsedCRL, err := x509.ParseRevocationList(crl)
if err != nil {
t.Fatalf("failed to parse CRL: %v", err)
}
if len(parsedCRL.RevokedCertificateEntries) != 5 {
t.Errorf("expected 5 revoked entries, got %d", len(parsedCRL.RevokedCertificateEntries))
}
// Verify reason codes are preserved
reasonCount := 0
for _, entry := range parsedCRL.RevokedCertificateEntries {
if entry.ReasonCode >= 0 {
reasonCount++
}
}
if reasonCount != 5 {
t.Errorf("expected all 5 entries to have reason codes, got %d", reasonCount)
}
t.Logf("CRL with %d reason codes generated successfully", reasonCount)
}
func TestSignOCSPResponse_Good(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
now := time.Now()
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
CertSerial: big.NewInt(12345),
CertStatus: 0, // good
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
if err != nil {
t.Fatalf("SignOCSPResponse failed: %v", err)
}
if resp == nil {
t.Fatal("OCSP response is nil")
}
if len(resp) == 0 {
t.Fatal("OCSP response is empty")
}
t.Logf("OCSP response for good cert generated: %d bytes", len(resp))
}
func TestSignOCSPResponse_Revoked(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
now := time.Now()
revokedAt := now.Add(-24 * time.Hour)
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
CertSerial: big.NewInt(12345),
CertStatus: 1, // revoked
RevokedAt: revokedAt,
RevocationReason: 1, // keyCompromise
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
if err != nil {
t.Fatalf("SignOCSPResponse failed: %v", err)
}
if resp == nil {
t.Fatal("OCSP response is nil")
}
if len(resp) == 0 {
t.Fatal("OCSP response is empty")
}
t.Logf("OCSP response for revoked cert generated: %d bytes", len(resp))
}
func TestSignOCSPResponse_Unknown(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
config := &local.Config{
CACommonName: "Test CA",
ValidityDays: 30,
}
connector := local.New(config, logger)
now := time.Now()
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
CertSerial: big.NewInt(12345),
CertStatus: 2, // unknown
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
if err != nil {
t.Fatalf("SignOCSPResponse failed: %v", err)
}
if resp == nil {
t.Fatal("OCSP response is nil")
}
if len(resp) == 0 {
t.Fatal("OCSP response is empty")
}
t.Logf("OCSP response for unknown cert generated: %d bytes", len(resp))
}
func TestSignOCSPResponse_BeforeCAInit(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
cfg := &local.Config{ValidityDays: 90}
connector := local.New(cfg, logger)
now := time.Now()
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
CertSerial: big.NewInt(999),
CertStatus: 0,
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
if err != nil {
t.Fatalf("SignOCSPResponse failed: %v", err)
}
if resp == nil || len(resp) == 0 {
t.Fatal("OCSP response is nil or empty")
}
t.Log("OCSP response generated with auto-initialized CA")
}
func TestGenerateCRL_SubCA(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
certPath, keyPath := generateTestSubCA(t, "rsa")
defer os.Remove(certPath)
defer os.Remove(keyPath)
config := &local.Config{
ValidityDays: 30,
CACertPath: certPath,
CAKeyPath: keyPath,
}
connector := local.New(config, logger)
entries := []issuer.RevokedCertEntry{
{SerialNumber: big.NewInt(555), RevokedAt: time.Now().Add(-12 * time.Hour), ReasonCode: 2},
}
crl, err := connector.GenerateCRL(ctx, entries)
if err != nil {
t.Fatalf("SubCA GenerateCRL failed: %v", err)
}
if crl == nil {
t.Fatal("CRL is nil")
}
parsedCRL, err := x509.ParseRevocationList(crl)
if err != nil {
t.Fatalf("failed to parse SubCA CRL: %v", err)
}
if len(parsedCRL.RevokedCertificateEntries) != 1 {
t.Errorf("expected 1 entry in SubCA CRL, got %d", len(parsedCRL.RevokedCertificateEntries))
}
t.Log("SubCA CRL generated successfully")
}
func TestSignOCSPResponse_SubCA(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
certPath, keyPath := generateTestSubCA(t, "ecdsa")
defer os.Remove(certPath)
defer os.Remove(keyPath)
config := &local.Config{
ValidityDays: 30,
CACertPath: certPath,
CAKeyPath: keyPath,
}
connector := local.New(config, logger)
now := time.Now()
resp, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
CertSerial: big.NewInt(777),
CertStatus: 0,
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
if err != nil {
t.Fatalf("SubCA SignOCSPResponse failed: %v", err)
}
if resp == nil || len(resp) == 0 {
t.Fatal("SubCA OCSP response is nil or empty")
}
t.Log("SubCA OCSP response generated successfully")
}
@@ -457,5 +457,15 @@ func parseSignResponse(respBody []byte) (certPEM string, chainPEM string, serial
return certPEM, chainPEM, serial, notBefore, notAfter, nil
}
// GenerateCRL is not supported by step-ca as step-ca provides its own CRL endpoint.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("step-ca provides its own CRL endpoint; use step-ca's /crl directly")
}
// SignOCSPResponse is not supported by step-ca as step-ca provides its own OCSP responder.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("step-ca provides its own OCSP responder; use step-ca's /ocsp directly")
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
+126
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"math/big"
"time"
"github.com/shankar0123/certctl/internal/domain"
@@ -14,6 +15,7 @@ import (
type CertificateService struct {
certRepo repository.CertificateRepository
revocationRepo repository.RevocationRepository
profileRepo repository.CertificateProfileRepository
policyService *PolicyService
auditService *AuditService
notificationSvc *NotificationService
@@ -48,6 +50,11 @@ func (s *CertificateService) SetIssuerRegistry(registry map[string]IssuerConnect
s.issuerRegistry = registry
}
// SetProfileRepo sets the profile repository for short-lived cert exemption in CRL/OCSP.
func (s *CertificateService) SetProfileRepo(repo repository.CertificateProfileRepository) {
s.profileRepo = repo
}
// List returns a paginated list of certificates matching the filter.
func (s *CertificateService) List(ctx context.Context, filter *repository.CertificateFilter) ([]*domain.ManagedCertificate, int, error) {
certs, total, err := s.certRepo.List(ctx, filter)
@@ -471,3 +478,122 @@ func (s *CertificateService) GetRevokedCertificates() ([]*domain.CertificateRevo
}
return s.revocationRepo.ListAll(context.Background())
}
// GenerateDERCRL generates a DER-encoded X.509 CRL for the given issuer.
// Short-lived certificates (profile TTL < 1 hour) are excluded from the CRL.
func (s *CertificateService) GenerateDERCRL(issuerID string) ([]byte, error) {
if s.revocationRepo == nil {
return nil, fmt.Errorf("revocation repository not configured")
}
if s.issuerRegistry == nil {
return nil, fmt.Errorf("issuer registry not configured")
}
issuerConn, ok := s.issuerRegistry[issuerID]
if !ok {
return nil, fmt.Errorf("issuer not found: %s", issuerID)
}
revocations, err := s.revocationRepo.ListAll(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to list revocations: %w", err)
}
// Filter to this issuer and convert to CRL entries.
// Short-lived certificates (profile TTL < 1 hour) are excluded — expiry is sufficient revocation.
var entries []CRLEntry
for _, rev := range revocations {
if rev.IssuerID != issuerID {
continue
}
// Check short-lived exemption: look up the cert's profile
if s.profileRepo != nil && s.certRepo != nil {
cert, err := s.certRepo.Get(context.Background(), rev.CertificateID)
if err == nil && cert.CertificateProfileID != "" {
profile, err := s.profileRepo.Get(context.Background(), cert.CertificateProfileID)
if err == nil && profile.IsShortLived() {
slog.Debug("skipping short-lived cert from CRL",
"certificate_id", rev.CertificateID,
"profile_id", cert.CertificateProfileID)
continue
}
}
}
// Parse serial number from hex string
serial := new(big.Int)
serial.SetString(rev.SerialNumber, 16)
entries = append(entries, CRLEntry{
SerialNumber: serial,
RevokedAt: rev.RevokedAt,
ReasonCode: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
})
}
return issuerConn.GenerateCRL(context.Background(), entries)
}
// GetOCSPResponse generates a signed OCSP response for the given certificate serial.
func (s *CertificateService) GetOCSPResponse(issuerID string, serialHex string) ([]byte, error) {
if s.revocationRepo == nil {
return nil, fmt.Errorf("revocation repository not configured")
}
if s.issuerRegistry == nil {
return nil, fmt.Errorf("issuer registry not configured")
}
issuerConn, ok := s.issuerRegistry[issuerID]
if !ok {
return nil, fmt.Errorf("issuer not found: %s", issuerID)
}
serial := new(big.Int)
serial.SetString(serialHex, 16)
now := time.Now()
// Short-lived cert exemption: if the cert's profile has TTL < 1 hour,
// always return "good" — expiry is sufficient revocation for short-lived certs.
if s.profileRepo != nil && s.certRepo != nil {
// Look up cert by serial through revocation table
rev, _ := s.revocationRepo.GetBySerial(context.Background(), serialHex)
if rev != nil {
cert, err := s.certRepo.Get(context.Background(), rev.CertificateID)
if err == nil && cert.CertificateProfileID != "" {
profile, err := s.profileRepo.Get(context.Background(), cert.CertificateProfileID)
if err == nil && profile.IsShortLived() {
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
CertSerial: serial,
CertStatus: 0, // good — short-lived exemption
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}
}
}
}
// Check if this serial is revoked
rev, err := s.revocationRepo.GetBySerial(context.Background(), serialHex)
if err != nil {
// Not revoked — return "good" status
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
CertSerial: serial,
CertStatus: 0, // good
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}
// Revoked
return issuerConn.SignOCSPResponse(context.Background(), OCSPSignRequest{
CertSerial: serial,
CertStatus: 1, // revoked
RevokedAt: rev.RevokedAt,
RevocationReason: domain.CRLReasonCode(domain.RevocationReason(rev.Reason)),
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
})
}
+26
View File
@@ -69,3 +69,29 @@ func (a *IssuerConnectorAdapter) RevokeCertificate(ctx context.Context, serial s
Reason: reasonPtr,
})
}
// GenerateCRL delegates to the underlying connector.
func (a *IssuerConnectorAdapter) GenerateCRL(ctx context.Context, entries []CRLEntry) ([]byte, error) {
// Convert service-layer CRLEntry to connector-layer RevokedCertEntry
connEntries := make([]issuer.RevokedCertEntry, len(entries))
for i, e := range entries {
connEntries[i] = issuer.RevokedCertEntry{
SerialNumber: e.SerialNumber,
RevokedAt: e.RevokedAt,
ReasonCode: e.ReasonCode,
}
}
return a.connector.GenerateCRL(ctx, connEntries)
}
// SignOCSPResponse delegates to the underlying connector.
func (a *IssuerConnectorAdapter) SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) {
return a.connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{
CertSerial: req.CertSerial,
CertStatus: req.CertStatus,
RevokedAt: req.RevokedAt,
RevocationReason: req.RevocationReason,
ThisUpdate: req.ThisUpdate,
NextUpdate: req.NextUpdate,
})
}
+156
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"math/big"
"testing"
"time"
@@ -87,6 +88,14 @@ func (m *mockConnectorLayerIssuer) GetOrderStatus(ctx context.Context, orderID s
}, nil
}
func (m *mockConnectorLayerIssuer) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return []byte("mock-crl-data"), nil
}
func (m *mockConnectorLayerIssuer) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return []byte("mock-ocsp-response"), nil
}
// Tests for IssueCertificate
func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) {
@@ -368,3 +377,150 @@ func TestIssuerConnectorAdapter_RevokeCertificate_EmptyReason(t *testing.T) {
t.Fatalf("RevokeCertificate with empty reason failed: %v", err)
}
}
// M15b: CRL and OCSP Adapter Tests
func TestIssuerConnectorAdapter_GenerateCRL_Success(t *testing.T) {
ctx := context.Background()
expectedCRL := []byte("DER-encoded-CRL-data")
mock := &mockConnectorLayerIssuer{
// Mock returns a valid DER CRL when GenerateCRL is called
}
adapter := NewIssuerConnectorAdapter(mock)
// Call GenerateCRL on adapter
crl, err := adapter.GenerateCRL(ctx, nil)
if err != nil {
t.Fatalf("GenerateCRL failed: %v", err)
}
if crl == nil {
t.Fatal("expected non-nil CRL, got nil")
}
// Verify we got the mock CRL bytes
if string(crl) != "mock-crl-data" {
t.Errorf("expected mock-crl-data, got %s", string(crl))
}
t.Log("CRL generation delegated to connector successfully")
}
func TestIssuerConnectorAdapter_GenerateCRL_WithEntries(t *testing.T) {
ctx := context.Background()
mock := &mockConnectorLayerIssuer{}
adapter := NewIssuerConnectorAdapter(mock)
// Create test entries
entries := []issuer.RevokedCertEntry{
{SerialNumber: big.NewInt(111), RevokedAt: time.Now(), ReasonCode: 1},
{SerialNumber: big.NewInt(222), RevokedAt: time.Now(), ReasonCode: 4},
}
crl, err := adapter.GenerateCRL(ctx, entries)
if err != nil {
t.Fatalf("GenerateCRL with entries failed: %v", err)
}
if crl == nil {
t.Fatal("expected non-nil CRL")
}
if len(crl) == 0 {
t.Fatal("expected non-empty CRL")
}
t.Logf("CRL with %d entries generated via adapter", len(entries))
}
func TestIssuerConnectorAdapter_SignOCSPResponse_Good(t *testing.T) {
ctx := context.Background()
mock := &mockConnectorLayerIssuer{}
adapter := NewIssuerConnectorAdapter(mock)
now := time.Now()
req := issuer.OCSPSignRequest{
CertSerial: big.NewInt(12345),
CertStatus: 0, // good
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
}
resp, err := adapter.SignOCSPResponse(ctx, req)
if err != nil {
t.Fatalf("SignOCSPResponse failed: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil OCSP response")
}
if len(resp) == 0 {
t.Fatal("expected non-empty OCSP response")
}
if string(resp) != "mock-ocsp-response" {
t.Errorf("expected mock-ocsp-response, got %s", string(resp))
}
t.Log("OCSP response for good cert signed via adapter")
}
func TestIssuerConnectorAdapter_SignOCSPResponse_Revoked(t *testing.T) {
ctx := context.Background()
mock := &mockConnectorLayerIssuer{}
adapter := NewIssuerConnectorAdapter(mock)
now := time.Now()
req := issuer.OCSPSignRequest{
CertSerial: big.NewInt(67890),
CertStatus: 1, // revoked
RevokedAt: now.Add(-24 * time.Hour),
RevocationReason: 1, // keyCompromise
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
}
resp, err := adapter.SignOCSPResponse(ctx, req)
if err != nil {
t.Fatalf("SignOCSPResponse for revoked cert failed: %v", err)
}
if resp == nil || len(resp) == 0 {
t.Fatal("expected non-empty OCSP response for revoked cert")
}
t.Log("OCSP response for revoked cert signed via adapter")
}
func TestIssuerConnectorAdapter_SignOCSPResponse_Unknown(t *testing.T) {
ctx := context.Background()
mock := &mockConnectorLayerIssuer{}
adapter := NewIssuerConnectorAdapter(mock)
now := time.Now()
req := issuer.OCSPSignRequest{
CertSerial: big.NewInt(99999),
CertStatus: 2, // unknown
ThisUpdate: now,
NextUpdate: now.Add(1 * time.Hour),
}
resp, err := adapter.SignOCSPResponse(ctx, req)
if err != nil {
t.Fatalf("SignOCSPResponse for unknown cert failed: %v", err)
}
if resp == nil || len(resp) == 0 {
t.Fatal("expected non-empty OCSP response for unknown cert")
}
t.Log("OCSP response for unknown cert signed via adapter")
}
+22
View File
@@ -11,6 +11,7 @@ import (
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"time"
"github.com/shankar0123/certctl/internal/domain"
@@ -39,6 +40,10 @@ type IssuerConnector interface {
RenewCertificate(ctx context.Context, commonName string, sans []string, csrPEM string) (*IssuanceResult, error)
// RevokeCertificate revokes a certificate by serial number with an optional reason.
RevokeCertificate(ctx context.Context, serial string, reason string) error
// GenerateCRL generates a DER-encoded X.509 CRL from the given revocation entries.
GenerateCRL(ctx context.Context, revokedCerts []CRLEntry) ([]byte, error)
// SignOCSPResponse signs an OCSP response for the given certificate serial.
SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error)
}
// IssuanceResult holds the result of a certificate issuance or renewal operation.
@@ -50,6 +55,23 @@ type IssuanceResult struct {
NotAfter time.Time
}
// CRLEntry represents a revoked certificate for CRL generation.
type CRLEntry struct {
SerialNumber *big.Int
RevokedAt time.Time
ReasonCode int
}
// OCSPSignRequest contains the parameters for OCSP response signing.
type OCSPSignRequest struct {
CertSerial *big.Int
CertStatus int // 0=good, 1=revoked, 2=unknown
RevokedAt time.Time
RevocationReason int
ThisUpdate time.Time
NextUpdate time.Time
}
// NewRenewalService creates a new renewal service.
func NewRenewalService(
certRepo repository.CertificateRepository,
+216
View File
@@ -408,3 +408,219 @@ func TestRevokeCertificate_HandlerInterfaceMethod(t *testing.T) {
t.Errorf("expected Revoked status, got %s", updated.Status)
}
}
// M15b: CRL and OCSP Service Tests
func TestGenerateDERCRL_Success(t *testing.T) {
svc, certRepo, revocationRepo, _ := newRevocationTestService()
// Add some revoked certificates to the repo
now := time.Now()
revocationRepo.Revocations = []*domain.CertificateRevocation{
{
SerialNumber: "SERIAL-001",
CertificateID: "cert-1",
IssuerID: "iss-local",
Reason: "keyCompromise",
RevokedAt: now.Add(-24 * time.Hour),
RevokedBy: "admin",
},
{
SerialNumber: "SERIAL-002",
CertificateID: "cert-2",
IssuerID: "iss-local",
Reason: "superseded",
RevokedAt: now.Add(-12 * time.Hour),
RevokedBy: "admin",
},
}
crl, err := svc.GenerateDERCRL(context.Background(), "iss-local")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if crl == nil {
t.Fatal("expected non-nil CRL")
}
if len(crl) == 0 {
t.Fatal("expected non-empty CRL")
}
t.Logf("DER CRL generated successfully: %d bytes", len(crl))
}
func TestGenerateDERCRL_EmptyCRL(t *testing.T) {
svc, _, revocationRepo, _ := newRevocationTestService()
// No revoked certs for this issuer
revocationRepo.Revocations = []*domain.CertificateRevocation{}
crl, err := svc.GenerateDERCRL(context.Background(), "iss-local")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if crl == nil {
t.Fatal("expected non-nil CRL even when empty")
}
if len(crl) == 0 {
t.Fatal("expected non-empty CRL bytes (at least the CRL structure)")
}
t.Logf("Empty DER CRL generated successfully: %d bytes", len(crl))
}
func TestGenerateDERCRL_IssuerNotFound(t *testing.T) {
svc, _, _, _ := newRevocationTestService()
// Try to generate CRL for unknown issuer
crl, err := svc.GenerateDERCRL(context.Background(), "iss-unknown")
// Should return error or nil CRL depending on implementation
if crl != nil && err == nil {
t.Error("expected error or nil CRL for unknown issuer")
}
t.Logf("GenerateDERCRL correctly handles unknown issuer")
}
func TestGetOCSPResponse_Good(t *testing.T) {
svc, certRepo, _, _ := newRevocationTestService()
// Add a non-revoked certificate
cert := &domain.ManagedCertificate{
ID: "cert-ocsp-good",
CommonName: "good.example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusActive,
ExpiresAt: time.Now().AddDate(1, 0, 0),
}
certRepo.AddCert(cert)
version := &domain.CertificateVersion{
ID: "ver-ocsp-good",
CertificateID: "cert-ocsp-good",
SerialNumber: "OCSP-GOOD-001",
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(1, 0, 0),
CreatedAt: time.Now(),
}
certRepo.Versions["cert-ocsp-good"] = []*domain.CertificateVersion{version}
// Request OCSP response for good cert
resp, err := svc.GetOCSPResponse(context.Background(), "iss-local", "OCSP-GOOD-001")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if resp == nil || len(resp) == 0 {
t.Fatal("expected non-empty OCSP response for good cert")
}
t.Logf("OCSP response for good cert generated: %d bytes", len(resp))
}
func TestGetOCSPResponse_Revoked(t *testing.T) {
svc, certRepo, revocationRepo, _ := newRevocationTestService()
now := time.Now()
// Add a revoked certificate
cert := &domain.ManagedCertificate{
ID: "cert-ocsp-revoked",
CommonName: "revoked.example.com",
IssuerID: "iss-local",
Status: domain.CertificateStatusRevoked,
RevokedAt: &now,
RevocationReason: "keyCompromise",
ExpiresAt: time.Now().AddDate(1, 0, 0),
}
certRepo.AddCert(cert)
version := &domain.CertificateVersion{
ID: "ver-ocsp-revoked",
CertificateID: "cert-ocsp-revoked",
SerialNumber: "OCSP-REVOKED-001",
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().AddDate(1, 0, 0),
CreatedAt: time.Now(),
}
certRepo.Versions["cert-ocsp-revoked"] = []*domain.CertificateVersion{version}
// Add revocation record
revocationRepo.Revocations = []*domain.CertificateRevocation{
{
SerialNumber: "OCSP-REVOKED-001",
CertificateID: "cert-ocsp-revoked",
IssuerID: "iss-local",
Reason: "keyCompromise",
RevokedAt: now.Add(-24 * time.Hour),
RevokedBy: "admin",
},
}
// Request OCSP response for revoked cert
resp, err := svc.GetOCSPResponse(context.Background(), "iss-local", "OCSP-REVOKED-001")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if resp == nil || len(resp) == 0 {
t.Fatal("expected non-empty OCSP response for revoked cert")
}
t.Logf("OCSP response for revoked cert generated: %d bytes", len(resp))
}
func TestGetOCSPResponse_Unknown(t *testing.T) {
svc, _, _, _ := newRevocationTestService()
// Request OCSP response for unknown cert
resp, err := svc.GetOCSPResponse(context.Background(), "iss-local", "UNKNOWN-SERIAL")
if err != nil {
t.Fatalf("expected no error (should return unknown status), got: %v", err)
}
// Response should indicate unknown status
if resp == nil || len(resp) == 0 {
t.Fatal("expected non-empty OCSP response even for unknown cert")
}
t.Logf("OCSP response for unknown cert generated: %d bytes", len(resp))
}
func TestGetOCSPResponse_IssuerNotFound(t *testing.T) {
svc, _, _, _ := newRevocationTestService()
// Request OCSP response for unknown issuer
resp, err := svc.GetOCSPResponse(context.Background(), "iss-unknown", "SOME-SERIAL")
// Should return error since issuer doesn't exist
if err == nil && resp != nil {
t.Error("expected error for unknown issuer")
}
t.Logf("GetOCSPResponse correctly handles unknown issuer")
}
func TestGetOCSPResponse_InvalidSerial(t *testing.T) {
svc, _, _, _ := newRevocationTestService()
// Request OCSP response with invalid serial format
resp, err := svc.GetOCSPResponse(context.Background(), "iss-local", "")
if err == nil && resp != nil {
// Empty serial might return unknown status; that's ok
t.Logf("Empty serial handled gracefully")
} else if err != nil {
t.Logf("Empty serial rejected with error: %v", err)
}
}
+14
View File
@@ -620,6 +620,20 @@ func (m *mockIssuerConnector) RevokeCertificate(ctx context.Context, serial stri
return nil
}
func (m *mockIssuerConnector) GenerateCRL(ctx context.Context, entries []CRLEntry) ([]byte, error) {
if m.Err != nil {
return nil, m.Err
}
return []byte("-----BEGIN X509 CRL-----\nmock-crl-data\n-----END X509 CRL-----"), nil
}
func (m *mockIssuerConnector) SignOCSPResponse(ctx context.Context, req OCSPSignRequest) ([]byte, error) {
if m.Err != nil {
return nil, m.Err
}
return []byte("mock-ocsp-response"), nil
}
// Constructor functions for mocks
func newMockCertificateRepository() *mockCertRepo {
+6
View File
@@ -88,6 +88,12 @@ export const triggerDeployment = (id: string, targetId: string) =>
body: JSON.stringify({ target_id: targetId }),
});
export const revokeCertificate = (id: string, reason: string) =>
fetchJSON<{ status: string }>(`${BASE}/certificates/${id}/revoke`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
// Agents
export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+14
View File
@@ -9,17 +9,31 @@ export interface Certificate {
owner_id: string;
team_id: string;
renewal_policy_id: string;
certificate_profile_id: string;
serial_number: string;
fingerprint: string;
key_algorithm: string;
key_size: number;
issued_at: string;
expires_at: string;
revoked_at?: string;
revocation_reason?: string;
tags: Record<string, string>;
created_at: string;
updated_at: string;
}
export const REVOCATION_REASONS = [
{ value: 'unspecified', label: 'Unspecified' },
{ value: 'keyCompromise', label: 'Key Compromise' },
{ value: 'caCompromise', label: 'CA Compromise' },
{ value: 'affiliationChanged', label: 'Affiliation Changed' },
{ value: 'superseded', label: 'Superseded' },
{ value: 'cessationOfOperation', label: 'Cessation of Operation' },
{ value: 'certificateHold', label: 'Certificate Hold' },
{ value: 'privilegeWithdrawn', label: 'Privilege Withdrawn' },
] as const;
export interface CertificateVersion {
id: string;
certificate_id: string;
+113 -5
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, getTargets } from '../api/client';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, getTargets } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
@@ -22,6 +23,8 @@ export default function CertificateDetailPage() {
const queryClient = useQueryClient();
const [showDeploy, setShowDeploy] = useState(false);
const [deployTargetId, setDeployTargetId] = useState('');
const [showRevoke, setShowRevoke] = useState(false);
const [revokeReason, setRevokeReason] = useState('unspecified');
const { data: cert, isLoading, error, refetch } = useQuery({
queryKey: ['certificate', id],
@@ -66,6 +69,16 @@ export default function CertificateDetailPage() {
},
});
const revokeMutation = useMutation({
mutationFn: () => revokeCertificate(id!, revokeReason),
onSuccess: () => {
setShowRevoke(false);
setRevokeReason('unspecified');
queryClient.invalidateQueries({ queryKey: ['certificate', id] });
queryClient.invalidateQueries({ queryKey: ['certificates'] });
},
});
if (isLoading) {
return (
<>
@@ -85,6 +98,9 @@ export default function CertificateDetailPage() {
}
const days = daysUntil(cert.expires_at);
const isRevoked = cert.status === 'Revoked';
const isArchived = cert.status === 'Archived';
const canRevoke = !isRevoked && !isArchived;
return (
<>
@@ -98,19 +114,27 @@ export default function CertificateDetailPage() {
</button>
<button
onClick={() => setShowDeploy(true)}
disabled={cert.status === 'Archived'}
disabled={isArchived || isRevoked}
className="btn btn-ghost text-xs border border-slate-600 disabled:opacity-50"
>
Deploy
</button>
<button
onClick={() => renewMutation.mutate()}
disabled={renewMutation.isPending || cert.status === 'Archived' || cert.status === 'RenewalInProgress'}
disabled={renewMutation.isPending || isArchived || isRevoked || cert.status === 'RenewalInProgress'}
className="btn btn-primary text-xs disabled:opacity-50"
>
{renewMutation.isPending ? 'Renewing...' : 'Trigger Renewal'}
</button>
{cert.status !== 'Archived' && (
{canRevoke && (
<button
onClick={() => setShowRevoke(true)}
className="btn btn-ghost text-xs text-amber-400 hover:text-amber-300 border border-amber-600/50"
>
Revoke
</button>
)}
{!isArchived && (
<button
onClick={() => { if (confirm('Archive this certificate? This cannot be undone.')) archiveMutation.mutate(); }}
disabled={archiveMutation.isPending}
@@ -148,6 +172,36 @@ export default function CertificateDetailPage() {
Failed to archive: {archiveMutation.error instanceof Error ? archiveMutation.error.message : 'Unknown error'}
</div>
)}
{revokeMutation.isSuccess && (
<div className="bg-amber-500/10 border border-amber-500/20 text-amber-400 rounded-lg px-4 py-3 text-sm">
Certificate revoked successfully. It has been added to the CRL.
</div>
)}
{revokeMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
Failed to revoke: {revokeMutation.error instanceof Error ? revokeMutation.error.message : 'Unknown error'}
</div>
)}
{/* Revocation Banner */}
{isRevoked && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<div>
<div className="text-sm font-medium text-red-400">Certificate Revoked</div>
<div className="text-xs text-slate-400 mt-0.5">
Reason: {REVOCATION_REASONS.find(r => r.value === cert.revocation_reason)?.label || cert.revocation_reason || 'Unspecified'}
{cert.revoked_at && <> &middot; Revoked {formatDateTime(cert.revoked_at)}</>}
</div>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Certificate Info */}
@@ -169,15 +223,28 @@ export default function CertificateDetailPage() {
<h3 className="text-sm font-semibold text-slate-300 mb-4">Lifecycle</h3>
<InfoRow label="Issued" value={formatDate(cert.issued_at)} />
<InfoRow label="Expires" value={
<span className={expiryColor(days)}>
<span className={isRevoked ? 'text-red-400 line-through' : expiryColor(days)}>
{formatDate(cert.expires_at)} ({days <= 0 ? 'expired' : `${days} days`})
</span>
} />
<InfoRow label="Environment" value={cert.environment || '—'} />
<InfoRow label="Issuer" value={cert.issuer_id} />
<InfoRow label="Profile" value={cert.certificate_profile_id || '—'} />
<InfoRow label="Renewal Policy" value={cert.renewal_policy_id || '—'} />
<InfoRow label="Owner" value={cert.owner_id} />
<InfoRow label="Team" value={cert.team_id} />
{isRevoked && (
<>
<InfoRow label="Revoked At" value={
<span className="text-red-400">{cert.revoked_at ? formatDateTime(cert.revoked_at) : '—'}</span>
} />
<InfoRow label="Revocation Reason" value={
<span className="text-red-400">
{REVOCATION_REASONS.find(r => r.value === cert.revocation_reason)?.label || cert.revocation_reason || '—'}
</span>
} />
</>
)}
<InfoRow label="Created" value={formatDateTime(cert.created_at)} />
<InfoRow label="Updated" value={formatDateTime(cert.updated_at)} />
</div>
@@ -220,6 +287,8 @@ export default function CertificateDetailPage() {
)}
</div>
</div>
{/* Deploy Modal */}
{showDeploy && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowDeploy(false)}>
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
@@ -253,6 +322,45 @@ export default function CertificateDetailPage() {
</div>
</div>
)}
{/* Revoke Modal */}
{showRevoke && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowRevoke(false)}>
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-red-400 mb-2">Revoke Certificate</h2>
<p className="text-sm text-slate-400 mb-4">
This action cannot be undone. The certificate will be added to the CRL and marked as revoked.
</p>
{revokeMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">
{revokeMutation.error instanceof Error ? revokeMutation.error.message : 'Unknown error'}
</div>
)}
<label className="text-xs text-slate-400 block mb-2">Revocation Reason (RFC 5280)</label>
<select
value={revokeReason}
onChange={e => setRevokeReason(e.target.value)}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 mb-4"
>
{REVOCATION_REASONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
<div className="flex justify-end gap-3">
<button onClick={() => { setShowRevoke(false); setRevokeReason('unspecified'); }} className="btn btn-ghost text-sm">
Cancel
</button>
<button
onClick={() => revokeMutation.mutate()}
disabled={revokeMutation.isPending}
className="btn text-sm bg-red-600 hover:bg-red-500 text-white disabled:opacity-50"
>
{revokeMutation.isPending ? 'Revoking...' : 'Revoke Certificate'}
</button>
</div>
</div>
</div>
)}
</>
);
}
+1
View File
@@ -167,6 +167,7 @@ export default function CertificatesPage() {
<option value="Active">Active</option>
<option value="Expiring">Expiring</option>
<option value="Expired">Expired</option>
<option value="Revoked">Revoked</option>
<option value="RenewalInProgress">Renewal In Progress</option>
<option value="Archived">Archived</option>
</select>