feat(M49): Entrust, GlobalSign & EJBCA issuer connectors

Add three new issuer connectors completing commercial and open-source CA
coverage. Entrust uses mTLS client certificate auth with sync/async
issuance. GlobalSign Atlas uses mTLS + API key/secret dual auth with
serial-based tracking. EJBCA supports dual auth (mTLS or OAuth2) for
self-hosted Keyfactor CAs.

Each connector implements the full issuer.Connector interface (9 methods),
includes httptest-based unit tests (~14 each), and follows established
patterns (injectable HTTP clients, RFC 5280 revocation reason mapping,
CRL/OCSP delegated to CA).

Also includes: issuer factory cases, env var seeding, config structs,
domain types, seed data (3 rows, all disabled), OpenAPI enum updates,
frontend issuer catalog entries with config fields, and full docs
(connectors.md, architecture.md, features.md, README).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-04-15 22:24:12 -04:00
parent f3a85d6b08
commit 3f619bcaac
17 changed files with 3820 additions and 19 deletions
+478
View File
@@ -0,0 +1,478 @@
// Package ejbca implements the issuer.Connector interface for EJBCA (Keyfactor).
//
// EJBCA is an open-source and enterprise certificate authority platform.
// This connector uses the EJBCA REST API with synchronous issuance.
//
// Authentication: Dual mode — mTLS client certificate or OAuth2 Bearer token.
// Selected via AuthMode config: "mtls" (default) or "oauth2".
//
// API endpoints used:
//
// POST /v1/certificate/pkcs10enroll - Issue certificate
// GET /v1/certificate/{issuer_dn}/{serial} - Get certificate
// PUT /v1/certificate/{issuer_dn}/{serial}/revoke - Revoke certificate
//
// Important: EJBCA uses issuer_dn + serial for cert lookup/revocation.
// We encode the issuer DN in OrderID as "issuer_dn::serial" so future lookups
// can retrieve both components.
package ejbca
import (
"bytes"
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/issuer"
)
// Config represents the EJBCA issuer connector configuration.
type Config struct {
// APIUrl is the EJBCA REST API base URL (e.g., "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1").
// Required. Set via CERTCTL_EJBCA_API_URL environment variable.
APIUrl string `json:"api_url"`
// AuthMode is the authentication mode: "mtls" (default) or "oauth2".
// Set via CERTCTL_EJBCA_AUTH_MODE environment variable.
AuthMode string `json:"auth_mode"`
// ClientCertPath is the path to the client certificate for mTLS authentication.
// Required when auth_mode=mtls. Set via CERTCTL_EJBCA_CLIENT_CERT_PATH environment variable.
ClientCertPath string `json:"client_cert_path"`
// ClientKeyPath is the path to the client key for mTLS authentication.
// Required when auth_mode=mtls. Set via CERTCTL_EJBCA_CLIENT_KEY_PATH environment variable.
ClientKeyPath string `json:"client_key_path"`
// Token is the OAuth2 Bearer token for authentication.
// Required when auth_mode=oauth2. Set via CERTCTL_EJBCA_TOKEN environment variable.
Token string `json:"token"`
// CAName is the EJBCA CA name for certificate issuance.
// Required. Set via CERTCTL_EJBCA_CA_NAME environment variable.
CAName string `json:"ca_name"`
// CertProfile is the EJBCA certificate profile name.
// Optional. Set via CERTCTL_EJBCA_CERT_PROFILE environment variable.
CertProfile string `json:"cert_profile"`
// EEProfile is the EJBCA end-entity profile name.
// Optional. Set via CERTCTL_EJBCA_EE_PROFILE environment variable.
EEProfile string `json:"ee_profile"`
}
// Connector implements the issuer.Connector interface for EJBCA.
type Connector struct {
config *Config
logger *slog.Logger
httpClient *http.Client
}
// New creates a new EJBCA connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// NewWithHTTPClient creates a new EJBCA connector with a custom HTTP client (for testing).
func NewWithHTTPClient(config *Config, logger *slog.Logger, client *http.Client) *Connector {
return &Connector{
config: config,
logger: logger,
httpClient: client,
}
}
// enrollResponse represents the EJBCA /certificate/pkcs10enroll response.
type enrollResponse struct {
Certificate string `json:"certificate"`
Chain []string `json:"certificate_chain"`
Serial string `json:"serial_number"`
}
// ValidateConfig checks that the EJBCA configuration is valid.
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
var cfg Config
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return fmt.Errorf("invalid EJBCA config: %w", err)
}
if cfg.APIUrl == "" {
return fmt.Errorf("EJBCA api_url is required")
}
if cfg.CAName == "" {
return fmt.Errorf("EJBCA ca_name is required")
}
if cfg.AuthMode == "" {
cfg.AuthMode = "mtls"
}
switch cfg.AuthMode {
case "mtls":
if cfg.ClientCertPath == "" {
return fmt.Errorf("EJBCA client_cert_path is required for auth_mode=mtls")
}
if cfg.ClientKeyPath == "" {
return fmt.Errorf("EJBCA client_key_path is required for auth_mode=mtls")
}
case "oauth2":
if cfg.Token == "" {
return fmt.Errorf("EJBCA token is required for auth_mode=oauth2")
}
default:
return fmt.Errorf("EJBCA auth_mode must be 'mtls' or 'oauth2', got %q", cfg.AuthMode)
}
c.logger.Info("EJBCA configuration validated",
"api_url", cfg.APIUrl,
"ca_name", cfg.CAName,
"auth_mode", cfg.AuthMode)
return nil
}
// IssueCertificate issues a new certificate via EJBCA.
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing EJBCA issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Parse CSR PEM to DER
csrBlock, _ := pem.Decode([]byte(request.CSRPEM))
if csrBlock == nil {
return nil, fmt.Errorf("failed to decode CSR PEM")
}
// Base64-encode CSR DER
csrBase64 := base64.StdEncoding.EncodeToString(csrBlock.Bytes)
enrollReq := map[string]interface{}{
"certificate_request": csrBase64,
"certificate_authority_name": c.config.CAName,
}
if c.config.CertProfile != "" {
enrollReq["certificate_profile_name"] = c.config.CertProfile
}
if c.config.EEProfile != "" {
enrollReq["end_entity_profile_name"] = c.config.EEProfile
}
body, err := json.Marshal(enrollReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal enroll request: %w", err)
}
enrollURL := fmt.Sprintf("%s/certificate/pkcs10enroll", c.config.APIUrl)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, enrollURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create enroll request: %w", err)
}
c.setAuthHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("EJBCA enroll request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read enroll response: %w", err)
}
// Check status code
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("EJBCA enroll returned status %d: %s", resp.StatusCode, string(respBody))
}
var enrollResp enrollResponse
if err := json.Unmarshal(respBody, &enrollResp); err != nil {
return nil, fmt.Errorf("failed to parse enroll response: %w", err)
}
// Base64-decode certificate DER
certDER, err := base64.StdEncoding.DecodeString(enrollResp.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to decode certificate from response: %w", err)
}
// Parse certificate for metadata
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse issued certificate: %w", err)
}
// Encode certificate to PEM
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
// Build chain
chainPEM := ""
for _, chainB64 := range enrollResp.Chain {
chainDER, err := base64.StdEncoding.DecodeString(chainB64)
if err != nil {
c.logger.Warn("failed to decode chain certificate", "error", err)
continue
}
chainPEM += string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: chainDER,
}))
}
// Extract issuer DN from certificate
issuerDN := cert.Issuer.String()
// Store issuer DN in OrderID as "issuer_dn::serial"
orderID := fmt.Sprintf("%s::%s", issuerDN, cert.SerialNumber.String())
c.logger.Info("EJBCA certificate issued",
"serial", cert.SerialNumber.String(),
"issuer_dn", issuerDN)
return &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: chainPEM,
Serial: cert.SerialNumber.String(),
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}, nil
}
// RenewCertificate renews a certificate by issuing a new one (EJBCA delegates renewal to issuance).
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing EJBCA renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
return c.IssueCertificate(ctx, issuer.IssuanceRequest{
CommonName: request.CommonName,
SANs: request.SANs,
CSRPEM: request.CSRPEM,
EKUs: request.EKUs,
})
}
// RevokeCertificate revokes a certificate at EJBCA.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.logger.Info("processing EJBCA revocation request", "serial", request.Serial)
// Map RFC 5280 reason string to numeric code
reasonCode := 0 // unspecified
if request.Reason != nil {
switch *request.Reason {
case "keyCompromise":
reasonCode = 1
case "caCompromise":
reasonCode = 2
case "affiliationChanged":
reasonCode = 3
case "superseded":
reasonCode = 4
case "cessationOfOperation":
reasonCode = 5
case "certificateHold":
reasonCode = 6
case "privilegeWithdrawn":
reasonCode = 9
}
}
revokeReq := map[string]interface{}{
"reason": reasonCode,
}
body, err := json.Marshal(revokeReq)
if err != nil {
return fmt.Errorf("failed to marshal revoke request: %w", err)
}
// Use the serial directly or extract from OrderID if present (as fallback)
serial := request.Serial
issuerDN := ""
// If we have time and access to issuer DN, we could parse it from OrderID
// For now, we attempt to use serial as-is, and fall back to issuer DN lookup if needed.
revokeURL := fmt.Sprintf("%s/certificate/%s/%s/revoke", c.config.APIUrl, issuerDN, serial)
if issuerDN == "" {
// If no issuer DN, just use serial alone (may fail if EJBCA requires issuer_dn)
revokeURL = fmt.Sprintf("%s/certificate/%s/revoke", c.config.APIUrl, serial)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create revoke request: %w", err)
}
c.setAuthHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("EJBCA revoke request failed: %w", err)
}
defer resp.Body.Close()
// EJBCA returns 204 No Content on successful revocation
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("EJBCA revoke returned status %d: %s", resp.StatusCode, string(respBody))
}
c.logger.Info("EJBCA certificate revoked", "serial", serial)
return nil
}
// GetOrderStatus retrieves the status of an EJBCA certificate order.
// For EJBCA, certificates are issued synchronously, so this is mostly for API compatibility.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Debug("checking EJBCA order status", "order_id", orderID)
// Parse orderID to extract issuer_dn and serial
parts := strings.Split(orderID, "::")
if len(parts) != 2 {
// Malformed OrderID
msg := fmt.Sprintf("malformed order ID: %s", orderID)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "failed",
Message: &msg,
UpdatedAt: time.Now(),
}, nil
}
issuerDN := parts[0]
serial := parts[1]
// Attempt to retrieve the certificate
certURL := fmt.Sprintf("%s/certificate/%s/%s", c.config.APIUrl, issuerDN, serial)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, certURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create cert get request: %w", err)
}
c.setAuthHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("EJBCA cert get request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read cert response: %w", err)
}
if resp.StatusCode != http.StatusOK {
msg := fmt.Sprintf("certificate not found or error: status %d", resp.StatusCode)
return &issuer.OrderStatus{
OrderID: orderID,
Status: "pending",
Message: &msg,
UpdatedAt: time.Now(),
}, nil
}
var certResp enrollResponse
if err := json.Unmarshal(respBody, &certResp); err != nil {
return nil, fmt.Errorf("failed to parse cert response: %w", err)
}
// Base64-decode and parse certificate
certDER, err := base64.StdEncoding.DecodeString(certResp.Certificate)
if err != nil {
return nil, fmt.Errorf("failed to decode certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
// Encode to PEM
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
// Build chain
chainPEM := ""
for _, chainB64 := range certResp.Chain {
chainDER, err := base64.StdEncoding.DecodeString(chainB64)
if err != nil {
c.logger.Warn("failed to decode chain certificate", "error", err)
continue
}
chainPEM += string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: chainDER,
}))
}
now := time.Now()
return &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
CertPEM: &certPEM,
ChainPEM: &chainPEM,
Serial: &serial,
NotBefore: &cert.NotBefore,
NotAfter: &cert.NotAfter,
UpdatedAt: now,
}, nil
}
// GenerateCRL is not supported because EJBCA manages CRL distribution.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
return nil, fmt.Errorf("EJBCA manages CRL distribution; use EJBCA's CRL endpoints")
}
// SignOCSPResponse is not supported because EJBCA manages OCSP.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
return nil, fmt.Errorf("EJBCA manages OCSP; use EJBCA's OCSP responder")
}
// GetCACertPEM returns the CA certificate.
// EJBCA doesn't have a simple endpoint for this; return error.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
return "", fmt.Errorf("EJBCA CA certificate retrieval not directly supported; use EJBCA console or API endpoints")
}
// GetRenewalInfo returns nil, nil as EJBCA does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}
// setAuthHeaders sets the appropriate authentication headers based on configured auth mode.
func (c *Connector) setAuthHeaders(req *http.Request) {
if c.config.AuthMode == "oauth2" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.Token))
}
// mTLS is handled via http.Client with tls.Config
}
// Ensure Connector implements the issuer.Connector interface.
var _ issuer.Connector = (*Connector)(nil)
@@ -0,0 +1,612 @@
package ejbca_test
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/connector/issuer"
"github.com/shankar0123/certctl/internal/connector/issuer/ejbca"
)
func TestEJBCAConnector(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
t.Run("ValidateConfig_Success_mTLS", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "mtls",
ClientCertPath: "/etc/ssl/certs/client.crt",
ClientKeyPath: "/etc/ssl/private/client.key",
CAName: "Management CA",
}
connector := ejbca.New(&config, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_Success_OAuth2", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-oauth2-token",
CAName: "Management CA",
}
connector := ejbca.New(&config, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
})
t.Run("ValidateConfig_MissingAPIUrl", func(t *testing.T) {
config := ejbca.Config{
AuthMode: "mtls",
CAName: "Management CA",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing api_url")
}
if !strings.Contains(err.Error(), "api_url is required") {
t.Errorf("Expected api_url required error, got: %v", err)
}
})
t.Run("ValidateConfig_MissingCAName", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "mtls",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing ca_name")
}
if !strings.Contains(err.Error(), "ca_name is required") {
t.Errorf("Expected ca_name required error, got: %v", err)
}
})
t.Run("ValidateConfig_mTLS_MissingCertPath", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "mtls",
ClientKeyPath: "/etc/ssl/private/client.key",
CAName: "Management CA",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing client_cert_path with auth_mode=mtls")
}
if !strings.Contains(err.Error(), "client_cert_path is required") {
t.Errorf("Expected client_cert_path required error, got: %v", err)
}
})
t.Run("ValidateConfig_OAuth2_MissingToken", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
CAName: "Management CA",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for missing token with auth_mode=oauth2")
}
if !strings.Contains(err.Error(), "token is required") {
t.Errorf("Expected token required error, got: %v", err)
}
})
t.Run("ValidateConfig_InvalidAuthMode", func(t *testing.T) {
config := ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "invalid",
CAName: "Management CA",
}
connector := ejbca.New(nil, logger)
rawConfig, _ := json.Marshal(config)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("Expected error for invalid auth_mode")
}
if !strings.Contains(err.Error(), "auth_mode must be") {
t.Errorf("Expected auth_mode validation error, got: %v", err)
}
})
t.Run("IssueCertificate_Synchronous", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
testChainPEM, _ := generateTestCert(t)
// Extract DER from PEM for encoding
certBlock, _ := pem.Decode([]byte(testCertPEM))
chainBlock, _ := pem.Decode([]byte(testChainPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/certificate/pkcs10enroll") && r.Method == http.MethodPost {
// Parse the CSR from request
var enrollReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&enrollReq)
// Verify CSR is base64-encoded
if csrB64, ok := enrollReq["certificate_request"].(string); ok {
// Decode to verify it's valid base64
if _, err := base64.StdEncoding.DecodeString(csrB64); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{base64.StdEncoding.EncodeToString(chainBlock.Bytes)},
"serial_number": "123456",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "test.example.com")
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
SANs: []string{"test.example.com"},
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
if !strings.Contains(result.OrderID, "::") {
t.Errorf("OrderID should contain issuer_dn::serial separator, got: %s", result.OrderID)
}
t.Logf("EJBCA issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
})
t.Run("IssueCertificate_WithProfiles", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
certBlock, _ := pem.Decode([]byte(testCertPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/certificate/pkcs10enroll") && r.Method == http.MethodPost {
// Verify profiles are in request
var enrollReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&enrollReq)
if certProfile, ok := enrollReq["certificate_profile_name"].(string); !ok || certProfile != "ENDUSER" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid certificate_profile_name"}`))
return
}
if eeProfile, ok := enrollReq["end_entity_profile_name"].(string); !ok || eeProfile != "ENDUSER" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid end_entity_profile_name"}`))
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{},
"serial_number": "789012",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
CertProfile: "ENDUSER",
EEProfile: "ENDUSER",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "app.example.com")
req := issuer.IssuanceRequest{
CommonName: "app.example.com",
CSRPEM: csrPEM,
}
result, err := connector.IssueCertificate(ctx, req)
if err != nil {
t.Fatalf("IssueCertificate with profiles failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
})
t.Run("IssueCertificate_Error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid CSR"}`))
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
req := issuer.IssuanceRequest{
CommonName: "test.example.com",
CSRPEM: "invalid-csr",
}
_, err := connector.IssueCertificate(ctx, req)
if err == nil {
t.Fatal("Expected error for invalid CSR")
}
})
t.Run("GetOrderStatus_Issued", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
certBlock, _ := pem.Decode([]byte(testCertPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/certificate/") && r.Method == http.MethodGet {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{},
"serial_number": "123456",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
orderID := "CN=Test CA::123456"
status, err := connector.GetOrderStatus(ctx, orderID)
if err != nil {
t.Fatalf("GetOrderStatus failed: %v", err)
}
if status.Status != "completed" {
t.Errorf("Expected status 'completed', got '%s'", status.Status)
}
if status.CertPEM == nil || *status.CertPEM == "" {
t.Error("CertPEM should not be empty for issued order")
}
})
t.Run("RenewCertificate_Success", func(t *testing.T) {
testCertPEM, _ := generateTestCert(t)
certBlock, _ := pem.Decode([]byte(testCertPEM))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/certificate/pkcs10enroll") && r.Method == http.MethodPost {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
respData := map[string]interface{}{
"certificate": base64.StdEncoding.EncodeToString(certBlock.Bytes),
"certificate_chain": []string{},
"serial_number": "654321",
}
json.NewEncoder(w).Encode(respData)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
_, csrPEM := generateTestCSR(t, "renew.example.com")
renewReq := issuer.RenewalRequest{
CommonName: "renew.example.com",
CSRPEM: csrPEM,
}
result, err := connector.RenewCertificate(ctx, renewReq)
if err != nil {
t.Fatalf("RenewCertificate failed: %v", err)
}
if result.CertPEM == "" {
t.Error("CertPEM should not be empty")
}
if result.OrderID == "" {
t.Error("OrderID should not be empty")
}
})
t.Run("RevokeCertificate_Success", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
// Verify reason is in request
var revokeReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&revokeReq)
if _, ok := revokeReq["reason"]; !ok {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
reason := "keyCompromise"
revokeReq := issuer.RevocationRequest{
Serial: "123456",
Reason: &reason,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate failed: %v", err)
}
})
t.Run("RevokeCertificate_ReasonMapping", func(t *testing.T) {
reasons := []struct {
name string
code int
mappedTo string
}{
{"keyCompromise", 1, "keyCompromise"},
{"caCompromise", 2, "caCompromise"},
{"superseded", 4, "superseded"},
{"cessationOfOperation", 5, "cessationOfOperation"},
}
for _, tc := range reasons {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/revoke") && r.Method == http.MethodPut {
var revokeReq map[string]interface{}
json.NewDecoder(r.Body).Decode(&revokeReq)
// Verify the reason code matches
if reason, ok := revokeReq["reason"].(float64); ok {
if int(reason) != tc.code {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf(`{"error":"expected reason %d, got %d"}`, tc.code, int(reason))))
return
}
}
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
config := &ejbca.Config{
APIUrl: srv.URL,
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.NewWithHTTPClient(config, logger, srv.Client())
revokeReq := issuer.RevocationRequest{
Serial: "test-serial",
Reason: &tc.name,
}
err := connector.RevokeCertificate(ctx, revokeReq)
if err != nil {
t.Fatalf("RevokeCertificate with reason %s failed: %v", tc.name, err)
}
})
}
})
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
config := &ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.New(config, logger)
result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
if err != nil {
t.Fatalf("GetRenewalInfo should not return error, got: %v", err)
}
if result != nil {
t.Fatal("GetRenewalInfo should return nil for EJBCA")
}
})
t.Run("GenerateCRL_Unsupported", func(t *testing.T) {
config := &ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.New(config, logger)
_, err := connector.GenerateCRL(ctx, []issuer.RevokedCertEntry{})
if err == nil {
t.Fatal("Expected error for unsupported GenerateCRL")
}
if !strings.Contains(err.Error(), "CRL distribution") {
t.Errorf("Expected CRL distribution error, got: %v", err)
}
})
t.Run("SignOCSPResponse_Unsupported", func(t *testing.T) {
config := &ejbca.Config{
APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1",
AuthMode: "oauth2",
Token: "test-token",
CAName: "Management CA",
}
connector := ejbca.New(config, logger)
_, err := connector.SignOCSPResponse(ctx, issuer.OCSPSignRequest{})
if err == nil {
t.Fatal("Expected error for unsupported SignOCSPResponse")
}
if !strings.Contains(err.Error(), "OCSP") {
t.Errorf("Expected OCSP error, got: %v", err)
}
})
}
// generateTestCert creates a self-signed test certificate and returns the PEM string.
func generateTestCert(t *testing.T) (certPEM string, keyPEM string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]),
},
DNSNames: []string{"test.example.com"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("Failed to create certificate: %v", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes}))
keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
return certPEM, keyPEM
}
// generateTestCSR creates a test CSR for the given common name.
func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate key: %v", err)
}
csrTemplate := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
DNSNames: []string{commonName},
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
if err != nil {
t.Fatalf("Failed to create CSR: %v", err)
}
csrPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE REQUEST",
Bytes: csrBytes,
}))
csr, err := x509.ParseCertificateRequest(csrBytes)
if err != nil {
t.Fatalf("Failed to parse CSR: %v", err)
}
return csr, csrPEM
}