mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 19:59:07 +00:00
2690b6401a
Two CodeQL go/comparison-of-identical-expressions alerts in one sweep — both Warning severity, both real dead-code (not false positives). CodeQL detected that each comparison's LHS variable was provably constant. Alert #18 — internal/api/handler/scep.go:612 (extractCSRFields): challengePassword := "" transactionID := "" // ... loop populates challengePassword from CSR.Attributes ... for _, attr := range csr.Attributes { if attr.Type.Equal(oidChallengePassword) { // populates challengePassword ONLY — transactionID stays "" } } if transactionID == "" && csr.Subject.CommonName != "" { // ← always true transactionID = csr.Subject.CommonName } transactionID was initialized to "" and never reassigned before the check. The conditional was always true; the MVP path was effectively "unconditionally fall back to CN". The RFC 8894 path (tryParseRFC8894 above this function) extracts transaction-ID properly from PKCS#7 authenticatedAttributes; the MVP path is for lightweight legacy clients that send the raw CSR with no PKCS#7 wrapping, and CN-as-transaction-ID is sufficient there. Fix: drop the dead transactionID local var + dead conditional; unconditionally set transactionID = csr.Subject.CommonName. No behavioral change — the runtime semantics are identical to before (every valid invocation already took the fallback). The CN extraction stays robust because the empty-CN case still produces an empty transactionID, which downstream callers handle. Alert #19 — internal/connector/issuer/ejbca/ejbca.go:415 (RevokeCertificate): serial := request.Serial issuerDN := "" // (comment: "if we have time..." — TODO never followed up) revokeURL := fmt.Sprintf("%s/certificate/%s/%s/revoke", apiURL, issuerDN, serial) if issuerDN == "" { // ← always true revokeURL = fmt.Sprintf("%s/certificate/%s/revoke", apiURL, serial) } issuerDN was hardcoded to "" two lines above. The first revokeURL line was unreachable dead code; the conditional always fired and the serial-only URL always won. EJBCA's REST API has both /certificate/{issuer_dn}/{serial}/revoke and /certificate/{serial}/revoke endpoints; the serial-only form is correct for typical certctl deployments where one EJBCA CA maps to one certctl issuer config (no overlapping serial spaces). Fix: drop the dead first revokeURL + dead conditional; build revokeURL once via the serial-only endpoint. No behavioral change — the runtime URL was always the serial-only one. Comment retained + expanded to document the future-enhancement path (parse issuer DN from IssuanceResult metadata + use the DN-qualified endpoint when a multi-CA EJBCA deployment surfaces). Verified locally: gofmt: clean. go vet ./internal/api/handler/... + ./internal/connector/issuer/ejbca/...: exit 0. go test -short -count=1 ./internal/api/handler/... + ejbca/...: PASS. Both fixes are pure dead-code removal — runtime behavior is byte- identical to pre-edit. The existing test suites would have caught any actual behavioral change. References: https://github.com/certctl-io/certctl/security/code-scanning/18 https://github.com/certctl-io/certctl/security/code-scanning/19 Closes both alerts.
598 lines
20 KiB
Go
598 lines
20 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/certctl-io/certctl/internal/connector/issuer"
|
|
"github.com/certctl-io/certctl/internal/connector/issuer/mtlscache"
|
|
"github.com/certctl-io/certctl/internal/secret"
|
|
)
|
|
|
|
// 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.
|
|
//
|
|
// Type: *secret.Ref (audit fix #6 Phase 2). Wrapping the token in
|
|
// a Ref means: it never stringifies (Config marshals as
|
|
// "[redacted]"), the bytes are zeroed after each Use/WriteTo
|
|
// invocation (defeats heap-dump extraction), and outbound HTTP
|
|
// header writes go through Ref.WriteTo so the staging buffer is
|
|
// short-lived. JSON unmarshal of a string value populates the
|
|
// Ref via NewRefFromString.
|
|
Token *secret.Ref `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
|
|
|
|
// mtls caches the parsed client keypair + a precomputed
|
|
// *http.Transport so steady-state API calls don't re-parse
|
|
// the keypair on every request, AND picks up rotated certs
|
|
// on the next call without a process restart. nil on the
|
|
// OAuth2 path (auth_mode=oauth2) and on the test path
|
|
// (NewWithHTTPClient). Closes Top-10 fix #1 of the
|
|
// 2026-05-03 issuer-coverage audit.
|
|
mtls *mtlscache.Cache
|
|
}
|
|
|
|
// New creates a new EJBCA connector with the given configuration and logger.
|
|
//
|
|
// When config.AuthMode is "mtls" (or empty — mtls is the default), New
|
|
// builds an mtlscache.Cache from config.ClientCertPath + config.ClientKeyPath.
|
|
// The cache parses the keypair once and configures
|
|
// *http.Transport.TLSClientConfig so the client presents the cert on every
|
|
// request. Subsequent calls go through getHTTPClient, which calls
|
|
// RefreshIfStale on the cache — operators rotating the cert+key on disk
|
|
// (e.g. quarterly per security policy) get the new keypair on the next
|
|
// API call without a server restart. Closes Top-10 fix #1 of the
|
|
// 2026-05-03 issuer-coverage audit.
|
|
//
|
|
// When AuthMode is "oauth2", New returns a client with no transport
|
|
// customization (the OAuth2 Bearer header path is wired in
|
|
// setAuthHeaders). Any other AuthMode value returns (nil, error).
|
|
//
|
|
// Returns an error if mTLS cert/key load fails (missing file, malformed
|
|
// PEM, mismatched cert/key) so misconfigured operators get an immediate
|
|
// failure at issuer construction rather than a cryptic 401 at first
|
|
// issuance.
|
|
//
|
|
// Callers wanting to inject a pre-built *http.Client (tests, fake EJBCA
|
|
// servers) should use NewWithHTTPClient.
|
|
func New(config *Config, logger *slog.Logger) (*Connector, error) {
|
|
authMode := "mtls"
|
|
if config != nil && config.AuthMode != "" {
|
|
authMode = config.AuthMode
|
|
}
|
|
|
|
switch authMode {
|
|
case "mtls":
|
|
if config == nil || config.ClientCertPath == "" || config.ClientKeyPath == "" {
|
|
return nil, fmt.Errorf("EJBCA mTLS requires client_cert_path and client_key_path")
|
|
}
|
|
// Build the cache up-front so misconfigured operators fail fast
|
|
// at construction rather than discover a broken cert path on
|
|
// the first issuance call. mtlscache enforces TLS 1.2 floor
|
|
// (compat with on-prem EJBCA installs that predate TLS 1.3).
|
|
cache, err := mtlscache.New(config.ClientCertPath, config.ClientKeyPath, mtlscache.Options{
|
|
HTTPTimeout: 30 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("EJBCA mTLS cache build: %w", err)
|
|
}
|
|
return &Connector{
|
|
config: config,
|
|
logger: logger,
|
|
httpClient: cache.Client(),
|
|
mtls: cache,
|
|
}, nil
|
|
case "oauth2":
|
|
// OAuth2 path uses default transport; setAuthHeaders adds the
|
|
// Bearer header on every request.
|
|
return &Connector{
|
|
config: config,
|
|
logger: logger,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}, nil
|
|
default:
|
|
return nil, fmt.Errorf("EJBCA invalid auth_mode %q (must be \"mtls\" or \"oauth2\")", authMode)
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// getHTTPClient returns the HTTP client to use for an EJBCA API call.
|
|
// On the mTLS path (auth_mode=mtls), it calls RefreshIfStale on the
|
|
// mtlscache so a rotated keypair on disk is picked up before the next
|
|
// request — operators rotating their EJBCA client cert quarterly no
|
|
// longer need a server restart. On the OAuth2 path (auth_mode=oauth2)
|
|
// or the test path (NewWithHTTPClient), it returns c.httpClient as-is
|
|
// because there's no keypair to refresh. Closes Top-10 fix #1 of the
|
|
// 2026-05-03 issuer-coverage audit. Mirrors the Entrust/GlobalSign
|
|
// pattern from Bundle M of the 2026-05-01 audit.
|
|
func (c *Connector) getHTTPClient() (*http.Client, error) {
|
|
if c.mtls == nil {
|
|
return c.httpClient, nil
|
|
}
|
|
if err := c.mtls.RefreshIfStale(); err != nil {
|
|
return nil, fmt.Errorf("EJBCA mTLS cache refresh: %w", err)
|
|
}
|
|
return c.mtls.Client(), nil
|
|
}
|
|
|
|
// 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.IsEmpty() {
|
|
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")
|
|
|
|
client, err := c.getHTTPClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := client.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)
|
|
}
|
|
|
|
// EJBCA's REST API has two revoke endpoints:
|
|
// /certificate/{issuer_dn}/{serial}/revoke — DN-qualified (more
|
|
// robust when EJBCA
|
|
// has multiple CAs
|
|
// with overlapping
|
|
// serial spaces)
|
|
// /certificate/{serial}/revoke — serial-only (this
|
|
// connector's
|
|
// contract today)
|
|
//
|
|
// We currently use the serial-only endpoint; the issuer DN isn't
|
|
// preserved in IssuanceResult.OrderID and the cert isn't re-fetched
|
|
// on revoke. EJBCA installations with serial-uniqueness across all
|
|
// configured CAs (the typical certctl deployment shape — one EJBCA
|
|
// CA per certctl issuer config) work fine. CodeQL #19 flagged the
|
|
// pre-existing `if issuerDN == ""` dead-conditional where issuerDN
|
|
// was always empty; cleaned up here. Future enhancement (when /if
|
|
// a multi-CA EJBCA deployment surfaces): parse issuer DN from
|
|
// IssuanceResult metadata + use the DN-qualified endpoint.
|
|
serial := request.Serial
|
|
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")
|
|
|
|
client, err := c.getHTTPClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := client.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)
|
|
|
|
client, err := c.getHTTPClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := client.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. For OAuth2, the Bearer token is fetched from
|
|
// the *secret.Ref via Use; the staging buffer is zeroed after the
|
|
// header value is constructed (audit fix #6 Phase 2).
|
|
func (c *Connector) setAuthHeaders(req *http.Request) {
|
|
if c.config.AuthMode == "oauth2" && c.config.Token != nil {
|
|
_ = c.config.Token.Use(func(buf []byte) error {
|
|
req.Header.Set("Authorization", "Bearer "+string(buf))
|
|
return nil
|
|
})
|
|
}
|
|
// mTLS is handled via http.Client with tls.Config
|
|
}
|
|
|
|
// Ensure Connector implements the issuer.Connector interface.
|
|
var _ issuer.Connector = (*Connector)(nil)
|