mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
7cb453a336
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
479 lines
15 KiB
Go
479 lines
15 KiB
Go
// 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)
|