mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 16:28:58 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user