mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 11:48:59 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
589 lines
19 KiB
Go
589 lines
19 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)
|
|
}
|
|
|
|
// 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")
|
|
|
|
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)
|