Files
certctl/internal/connector/issuer/local/local.go
T
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

1236 lines
44 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
// Bundle-9 / Audit L-014 (Document the CA-key-in-process threat model):
//
// The local CA holds its private key in this process's heap (c.caSigner
// field on the Connector struct — historically c.caKey before the Signer
// abstraction was introduced — plus transient allocations during signing).
// Go does not provide a standard mlock equivalent, the GC does not zero
// released memory, and the runtime moves objects between generations
// during compaction.
//
// Threats this DOES protect against:
// - Disk-at-rest exposure (key file is mode 0600; key dir is enforced 0700
// by ensureKeyDirSecure; key bytes zeroed after marshal by
// marshalPrivateKeyAndZeroize).
// - Casual local-user enumeration of the key dir (parents 0700).
// - Byte-identical migration regression (M-028 round-trip pin in tests).
//
// Threats this does NOT protect against:
// - Attacker with a debugger or core-dump capability against the running
// process (CAP_SYS_PTRACE, gdb attach, /proc/pid/mem read, container
// coredump policy). The CA key WILL be recoverable from a heap snapshot.
// - Memory pressure swap-out on hosts without an encrypted swap device.
// - Cold-boot attacks against the host's RAM after kernel panic.
//
// Operators with stricter requirements MUST run the local CA mode against an
// HSM or KMS-backed signer (PKCS#11 / cloud KMS / TPM) — see the V3 Pro
// roadmap entry for KMS-backed issuance. The defense-in-depth measures here
// (key zeroization after marshal, 0700 directory, deprecated-API migration)
// reduce the window of exposure but do not close it; the source of truth
// for "the local CA key cannot leave the host process" is HSM-backed
// signing, not heap hygiene.
//
// Defense-in-depth carve-out — the file-on-disk leg:
//
// The above measures harden the file-on-disk + heap-resident key flow
// (signer.FileDriver). The Signer interface in internal/crypto/signer/
// is the seam that lets operators replace this flow entirely:
// - signer.FileDriver: the current behavior (key on disk, hardening above).
// - signer.PKCS11Driver (future): key never leaves the HSM token.
// - signer.CloudKMSDriver (future): key never leaves the cloud KMS.
//
// When the key lives in a hardware token / KMS, the file-on-disk caveats
// above DO NOT APPLY — the key is not on disk and not in the certctl
// process heap. The L-014 threat-model assumptions documented here
// describe the file-driver case; alternative drivers close the
// disk-exposure leg of the threat model.
package local
import (
"context"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"net"
"os"
"sync"
"time"
"golang.org/x/crypto/ocsp"
"github.com/certctl-io/certctl/internal/connector/issuer"
"github.com/certctl-io/certctl/internal/crypto/signer"
"github.com/certctl-io/certctl/internal/repository"
"github.com/certctl-io/certctl/internal/validation"
)
// Config represents the local CA issuer connector configuration.
type Config struct {
// CACommonName is the CN for the self-signed CA certificate.
// Defaults to "CertCtl Local CA". Ignored in sub-CA mode.
CACommonName string `json:"ca_common_name,omitempty"`
// ValidityDays is the number of days a certificate is valid.
// Defaults to 90.
ValidityDays int `json:"validity_days,omitempty"`
// CACertPath is the path to a PEM-encoded CA certificate file.
// When set along with CAKeyPath, the connector operates in sub-CA mode:
// it loads the CA cert+key from disk instead of generating a self-signed root.
// The loaded CA cert should be signed by an upstream CA (e.g., ADCS).
// All issued certificates will chain to the upstream root.
CACertPath string `json:"ca_cert_path,omitempty"`
// CAKeyPath is the path to a PEM-encoded CA private key file (RSA or ECDSA).
// Required when CACertPath is set.
CAKeyPath string `json:"ca_key_path,omitempty"`
// CRLDistributionPointURLs — production hardening II Phase 6. When
// non-empty, the local issuer auto-injects the RFC 5280 §4.2.1.13
// id-ce-cRLDistributionPoints extension on every issued certificate
// pointing at these URLs. Operators set this to certctl's own
// public CRL endpoint (e.g.
// https://certctl.example.com:8443/.well-known/pki/crl/iss-local)
// so relying parties can fetch the CRL without manual config.
//
// Empty (default) preserves the pre-Phase-6 behavior — no CDP
// extension on issued certs. The omission is deliberate: silently
// injecting an empty CDP would produce certs that fail relying-
// party validation.
CRLDistributionPointURLs []string `json:"crl_distribution_point_urls,omitempty"`
}
// ChainAssembler assembles the leaf-to-root PEM chain for a given
// IntermediateCA ID. The local connector calls this in tree mode at
// IssueCertificate time to populate IssuanceResult.ChainPEM. Defining
// the seam as a one-method interface inside the connector package
// avoids the import cycle that would arise from importing
// internal/service directly. *service.IntermediateCAService satisfies
// this implicitly.
type ChainAssembler interface {
AssembleChain(ctx context.Context, leafCAID string) (string, error)
}
// Connector implements the issuer.Connector interface for local certificate generation.
//
// It supports three modes (Rank 8 added the third):
//
// Self-signed mode (default):
// - Generates an ephemeral self-signed CA root on first use
// - Designed for development, testing, and demo purposes
// - CA certificate is lost on service restart
//
// Sub-CA mode (when CACertPath + CAKeyPath are set):
// - Loads a pre-signed CA cert+key from disk
// - The CA cert should be signed by an upstream CA (e.g., ADCS, enterprise root)
// - All issued certificates chain to the upstream root
// - Suitable for production when the upstream CA is trusted
//
// Tree mode (when HierarchyMode is "tree" + SetChainAssembler + SetTreeIssuingCAID
// have been wired):
// - Operator-managed N-level CA hierarchy backed by the
// intermediate_cas table.
// - Cert signing still uses c.caCert + c.caSigner (the operator
// pre-positions the issuing-leaf CA cert+key on disk via the same
// CACertPath/CAKeyPath that sub-CA mode uses).
// - Only the chain assembled into IssuanceResult.ChainPEM differs:
// instead of the static c.caCertPEM, the connector calls
// chainAssembler.AssembleChain(treeIssuingCAID), which walks the
// parent_ca_id ancestry up to the registered root.
// - byte-identical to single-sub-CA mode for any 1-level tree (the
// Rank 8 backwards-compat pin).
//
// Features:
// - Instant certificate issuance (no external CA required)
// - Full lifecycle support (issue, renew, revoke)
// - Proper X.509 certificate generation with SANs, serial numbers, and validity periods
//
// Limitations:
// - Revocation is tracked in memory only (not persistent)
// - In self-signed mode, CA is ephemeral
type Connector struct {
config *Config
logger *slog.Logger
mu sync.RWMutex
caSigner signer.Signer // wraps the historical caKey crypto.Signer; same lifecycle, same heap residency, same L-014 carve-out
caCert *x509.Certificate
caCertPEM string
subCA bool // true when loaded from disk (sub-CA mode)
revokedMap map[string]bool // serial -> revoked status
// Rank 8 — first-class CA hierarchy. Optional; when unset the
// connector behaves byte-identically to the pre-Rank-8 single-sub-CA
// flow. When set:
// - hierarchyMode == "tree" activates the tree-mode chain
// assembly (AssembleChain over the intermediate_cas table).
// - chainAssembler is the seam to *service.IntermediateCAService.
// - treeIssuingCAID is the leaf CA in the tree under which leaves
// are issued. Cert signing still uses c.caCert + c.caSigner; the
// operator pre-positions the matching cert+key on disk for the
// issuing-leaf CA via Config.CACertPath / Config.CAKeyPath.
hierarchyMode string
chainAssembler ChainAssembler
treeIssuingCAID string
// Optional dependencies — set after construction via the
// Set*-style helpers below. The Connector functions correctly with
// any subset of these unset (the Phase-2 responder-cert path falls
// back to direct CA-key signing for OCSP when not configured, and
// the issuer ID falls back to the empty string for the
// responder-row key).
issuerID string
ocspResponderRepo repository.OCSPResponderRepository
signerDriver signer.Driver
// ocspResponderRotationGrace is the window before NotAfter at
// which the responder cert is rotated. Default 7 days; tunable
// for tests + special operator deploys.
ocspResponderRotationGrace time.Duration
// ocspResponderValidity is how long a freshly-generated responder
// cert is valid for. Default 30 days; tunable.
ocspResponderValidity time.Duration
// ocspResponderKeyDir is where FileDriver-backed responder keys
// land. Empty = use the OS temp dir (fine for tests; production
// callers should set this to a hardened path via the setter).
ocspResponderKeyDir string
}
// New creates a new local CA connector with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
if config == nil {
config = &Config{}
}
// Set defaults
if config.CACommonName == "" {
config.CACommonName = "CertCtl Local CA"
}
if config.ValidityDays == 0 {
config.ValidityDays = 90
}
return &Connector{
config: config,
logger: logger,
revokedMap: make(map[string]bool),
ocspResponderRotationGrace: 7 * 24 * time.Hour, // 7 days
ocspResponderValidity: 30 * 24 * time.Hour, // 30 days
}
}
// SetOCSPResponderRepo wires the persistent store for the dedicated
// OCSP-responder cert per RFC 6960 §2.6. When unset, SignOCSPResponse
// falls back to signing with the CA key directly (the historical
// behaviour, preserved for callers that don't supply this dep).
//
// Production wiring lives in cmd/server/main.go alongside the issuer
// registry; tests inject a memory-backed repo via the same setter.
func (c *Connector) SetOCSPResponderRepo(repo repository.OCSPResponderRepository) {
c.mu.Lock()
defer c.mu.Unlock()
c.ocspResponderRepo = repo
}
// SetSignerDriver wires the driver used to generate + load the OCSP
// responder cert's private key. Required alongside SetOCSPResponderRepo
// for the dedicated-responder path; without it the SignOCSPResponse
// fallback (CA-key direct) takes over.
func (c *Connector) SetSignerDriver(d signer.Driver) {
c.mu.Lock()
defer c.mu.Unlock()
c.signerDriver = d
}
// SetIssuerID records the issuer ID so the responder row can be keyed
// off it. Without this the responder repo can't be consulted (an empty
// issuer ID would collide across local-issuer instances). Falls through
// to the fallback path when unset.
func (c *Connector) SetIssuerID(id string) {
c.mu.Lock()
defer c.mu.Unlock()
c.issuerID = id
}
// SetOCSPResponderRotationGrace overrides the default 7-day-before-expiry
// rotation window for the dedicated responder cert. Tests use a small
// value; operators with strict policies may set 14d or 30d.
func (c *Connector) SetOCSPResponderRotationGrace(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if d > 0 {
c.ocspResponderRotationGrace = d
}
}
// SetOCSPResponderValidity overrides the default 30-day validity for
// freshly-generated responder certs. Operators preferring shorter
// validity (with more frequent rotation) tune via this setter.
func (c *Connector) SetOCSPResponderValidity(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if d > 0 {
c.ocspResponderValidity = d
}
}
// SetOCSPResponderKeyDir sets the directory where FileDriver-backed
// responder keys are written. Empty means "let the driver choose"
// (typically the OS temp dir, fine for tests). Production callers MUST
// set this to a hardened path; the FileDriver-installed
// keystore.ensureKeyDirSecure equivalent applies the same 0700 +
// permission gates as the CA key directory.
func (c *Connector) SetOCSPResponderKeyDir(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
c.ocspResponderKeyDir = dir
}
// SetHierarchyMode wires the per-issuer CA-hierarchy posture (Rank 8).
// The empty string and "single" preserve the historical single-sub-CA
// flow byte-for-byte; "tree" activates the intermediate_cas-backed
// chain assembly. Callers that pass "tree" MUST also call
// SetChainAssembler + SetTreeIssuingCAID before issuing certs;
// otherwise the connector falls back to single-mode chain assembly.
func (c *Connector) SetHierarchyMode(mode string) {
c.mu.Lock()
defer c.mu.Unlock()
c.hierarchyMode = mode
}
// SetChainAssembler wires the leaf-to-root chain assembler used in
// tree mode. *service.IntermediateCAService satisfies the interface
// implicitly. Unset = falls back to single-mode chain assembly.
func (c *Connector) SetChainAssembler(a ChainAssembler) {
c.mu.Lock()
defer c.mu.Unlock()
c.chainAssembler = a
}
// SetTreeIssuingCAID records the IntermediateCA ID under which leaves
// are issued in tree mode. Used as the AssembleChain leafCAID input.
// Cert signing still uses the file-on-disk CA cert+key wired via
// Config.CACertPath / Config.CAKeyPath; this ID is purely for chain
// assembly.
func (c *Connector) SetTreeIssuingCAID(id string) {
c.mu.Lock()
defer c.mu.Unlock()
c.treeIssuingCAID = id
}
// ValidateConfig validates the local CA configuration.
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 local CA config: %w", err)
}
if cfg.ValidityDays < 1 {
return fmt.Errorf("validity_days must be at least 1")
}
// Sub-CA mode: both paths must be set or neither
if (cfg.CACertPath != "") != (cfg.CAKeyPath != "") {
return fmt.Errorf("ca_cert_path and ca_key_path must both be set for sub-CA mode")
}
// Validate paths exist if set
if cfg.CACertPath != "" {
if _, err := os.Stat(cfg.CACertPath); err != nil {
return fmt.Errorf("ca_cert_path not accessible: %w", err)
}
if _, err := os.Stat(cfg.CAKeyPath); err != nil {
return fmt.Errorf("ca_key_path not accessible: %w", err)
}
}
c.config = &cfg
if c.config.CACommonName == "" {
c.config.CACommonName = "CertCtl Local CA"
}
mode := "self-signed"
if cfg.CACertPath != "" {
mode = "sub-CA"
}
c.logger.Info("local CA configuration validated",
"mode", mode,
"ca_common_name", c.config.CACommonName,
"validity_days", c.config.ValidityDays)
return nil
}
// IssueCertificate issues a new certificate signed by the local CA.
//
// The process:
// 1. Initialize the CA if not already done
// 2. Parse the CSR from the request
// 3. Extract subject and SANs from the CSR
// 4. Generate a random serial number
// 5. Create an X.509 certificate with proper extensions (SANs, key usage, etc.)
// 6. Sign with the local CA key
// 7. Return the certificate PEM and CA chain PEM
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing local CA issuance request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Initialize CA if needed
if err := c.ensureCA(ctx); err != nil {
c.logger.Error("failed to initialize CA", "error", err)
return nil, fmt.Errorf("CA initialization failed: %w", err)
}
// Parse CSR
csrBlock, _ := pem.Decode([]byte(request.CSRPEM))
if csrBlock == nil || csrBlock.Type != "CERTIFICATE REQUEST" {
return nil, fmt.Errorf("invalid CSR PEM format")
}
csr, err := x509.ParseCertificateRequest(csrBlock.Bytes)
if err != nil {
c.logger.Error("failed to parse CSR", "error", err)
return nil, fmt.Errorf("invalid CSR: %w", err)
}
// Verify CSR signature
if err := csr.CheckSignature(); err != nil {
c.logger.Error("CSR signature verification failed", "error", err)
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
}
// Bundle-9 / Audit L-012 (CWE-1007 + CWE-176): refuse CSRs whose CN/SANs
// contain Unicode that could be used for IDN homograph impersonation,
// RTL/LTR rendering attacks, zero-width hidden content, or control
// characters. Pure-IDN labels are allowed; mixed-script labels are not.
if err := validateCSRUnicode(csr, request.SANs); err != nil {
c.logger.Error("CSR unicode validation failed", "error", err)
return nil, err
}
// Generate certificate with EKUs and MaxTTL from request
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds, request.MustStaple)
if err != nil {
c.logger.Error("failed to generate certificate", "error", err)
return nil, fmt.Errorf("certificate generation failed: %w", err)
}
chainPEM, err := c.resolveChainPEM(ctx)
if err != nil {
c.logger.Error("failed to assemble chain", "error", err)
return nil, fmt.Errorf("chain assembly failed: %w", err)
}
// Create order ID (use serial as order ID for simplicity)
orderID := fmt.Sprintf("local-%s", serial)
result := &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: chainPEM,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}
c.logger.Info("certificate issued successfully",
"serial", serial,
"common_name", request.CommonName,
"not_after", cert.NotAfter)
return result, nil
}
// RenewCertificate renews a certificate by issuing a new one with the same identifiers.
// For the local CA, this is functionally identical to IssueCertificate.
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
c.logger.Info("processing local CA renewal request",
"common_name", request.CommonName,
"san_count", len(request.SANs))
// Initialize CA if needed
if err := c.ensureCA(ctx); err != nil {
c.logger.Error("failed to initialize CA", "error", err)
return nil, fmt.Errorf("CA initialization failed: %w", err)
}
// Parse CSR
csrBlock, _ := pem.Decode([]byte(request.CSRPEM))
if csrBlock == nil || csrBlock.Type != "CERTIFICATE REQUEST" {
return nil, fmt.Errorf("invalid CSR PEM format")
}
csr, err := x509.ParseCertificateRequest(csrBlock.Bytes)
if err != nil {
c.logger.Error("failed to parse CSR", "error", err)
return nil, fmt.Errorf("invalid CSR: %w", err)
}
// Verify CSR signature
if err := csr.CheckSignature(); err != nil {
c.logger.Error("CSR signature verification failed", "error", err)
return nil, fmt.Errorf("CSR signature verification failed: %w", err)
}
// Bundle-9 / Audit L-012: same unicode safety check as IssueCertificate.
if err := validateCSRUnicode(csr, request.SANs); err != nil {
c.logger.Error("CSR unicode validation failed", "error", err)
return nil, err
}
// Generate certificate with EKUs and MaxTTL from request
cert, certPEM, serial, err := c.generateCertificate(csr, request.SANs, request.EKUs, request.MaxTTLSeconds, request.MustStaple)
if err != nil {
c.logger.Error("failed to generate certificate", "error", err)
return nil, fmt.Errorf("certificate generation failed: %w", err)
}
chainPEM, err := c.resolveChainPEM(ctx)
if err != nil {
c.logger.Error("failed to assemble chain", "error", err)
return nil, fmt.Errorf("chain assembly failed: %w", err)
}
// Create order ID
orderID := fmt.Sprintf("local-%s", serial)
if request.OrderID != nil {
orderID = *request.OrderID
}
result := &issuer.IssuanceResult{
CertPEM: certPEM,
ChainPEM: chainPEM,
Serial: serial,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
OrderID: orderID,
}
c.logger.Info("certificate renewed successfully",
"serial", serial,
"common_name", request.CommonName,
"not_after", cert.NotAfter)
return result, nil
}
// resolveChainPEM returns the chain bytes the local connector attaches
// to IssuanceResult. In single-sub-CA mode (or when tree-mode wiring
// is incomplete) it returns the historical c.caCertPEM byte-for-byte
// — the Rank 8 backwards-compat pin. In tree mode it delegates to
// the registered ChainAssembler, which walks the parent_ca_id ancestry
// over the intermediate_cas table.
func (c *Connector) resolveChainPEM(ctx context.Context) (string, error) {
c.mu.RLock()
mode := c.hierarchyMode
asm := c.chainAssembler
leaf := c.treeIssuingCAID
fallback := c.caCertPEM
c.mu.RUnlock()
if mode == "tree" && asm != nil && leaf != "" {
chain, err := asm.AssembleChain(ctx, leaf)
if err != nil {
return "", err
}
return chain, nil
}
return fallback, nil
}
// RevokeCertificate revokes a certificate by marking it in the in-memory revocation map.
// This is a no-op for practical purposes but tracks revocation state in memory.
// Note: Revocation is not persistent and is lost on service restart.
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
c.mu.Lock()
defer c.mu.Unlock()
c.revokedMap[request.Serial] = true
reason := "unspecified"
if request.Reason != nil {
reason = *request.Reason
}
c.logger.Info("certificate revoked",
"serial", request.Serial,
"reason", reason)
return nil
}
// GetOrderStatus returns the status of an issuance or renewal order.
// For the local CA, orders complete immediately, so this always returns "completed" status.
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
c.logger.Info("fetching local CA order status", "order_id", orderID)
// Local CA orders complete immediately
status := &issuer.OrderStatus{
OrderID: orderID,
Status: "completed",
UpdatedAt: time.Now(),
}
return status, nil
}
// ensureCA initializes the CA certificate and key if not already done.
// In sub-CA mode (CACertPath + CAKeyPath set), loads from disk.
// Otherwise, generates an ephemeral self-signed CA.
func (c *Connector) ensureCA(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.caSigner != nil {
return nil // CA already initialized
}
if c.config.CACertPath != "" && c.config.CAKeyPath != "" {
return c.loadCAFromDisk()
}
return c.generateSelfSignedCA()
}
// loadCAFromDisk loads a CA certificate and private key from PEM files on disk.
// This enables sub-CA mode where certctl operates as a subordinate CA under an
// enterprise root (e.g., ADCS). The loaded cert should have IsCA=true and
// KeyUsageCertSign set by the upstream CA.
func (c *Connector) loadCAFromDisk() error {
c.logger.Info("loading CA from disk (sub-CA mode)",
"cert_path", c.config.CACertPath,
"key_path", c.config.CAKeyPath)
// Load CA certificate
certPEM, err := os.ReadFile(c.config.CACertPath)
if err != nil {
return fmt.Errorf("failed to read CA certificate: %w", err)
}
certBlock, _ := pem.Decode(certPEM)
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
return fmt.Errorf("invalid CA certificate PEM (expected CERTIFICATE block)")
}
caCert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse CA certificate: %w", err)
}
// Validate CA certificate properties
if !caCert.IsCA {
return fmt.Errorf("loaded certificate is not a CA (BasicConstraints.IsCA=false)")
}
if caCert.KeyUsage&x509.KeyUsageCertSign == 0 {
return fmt.Errorf("loaded CA certificate does not have KeyUsageCertSign")
}
// Validate CA certificate validity window (M-5, CWE-672).
// An expired or not-yet-valid sub-CA produces child certificates that any
// RFC 5280 path-validator will reject. Fail closed at load time so operators
// learn about it at startup, not at 3am when a renewal cycle silently
// starts minting broken certs. See audit finding M-5.
now := time.Now()
if now.After(caCert.NotAfter) {
return fmt.Errorf("CA certificate %q has expired (not_after=%s, now=%s)",
caCert.Subject.CommonName,
caCert.NotAfter.UTC().Format(time.RFC3339),
now.UTC().Format(time.RFC3339))
}
if now.Before(caCert.NotBefore) {
return fmt.Errorf("CA certificate %q is not yet valid (not_before=%s, now=%s)",
caCert.Subject.CommonName,
caCert.NotBefore.UTC().Format(time.RFC3339),
now.UTC().Format(time.RFC3339))
}
// Load CA private key (supports RSA and ECDSA)
keyPEM, err := os.ReadFile(c.config.CAKeyPath)
if err != nil {
return fmt.Errorf("failed to read CA private key: %w", err)
}
keyBlock, _ := pem.Decode(keyPEM)
if keyBlock == nil {
return fmt.Errorf("invalid CA private key PEM")
}
caKey, err := signer.ParsePrivateKey(keyBlock)
if err != nil {
return fmt.Errorf("failed to parse CA private key: %w", err)
}
caSigner, err := signer.Wrap(caKey)
if err != nil {
return fmt.Errorf("failed to wrap CA private key as signer: %w", err)
}
// Encode CA cert PEM for chain responses
c.caSigner = caSigner
c.caCert = caCert
c.caCertPEM = string(certPEM)
c.subCA = true
c.logger.Info("sub-CA initialized from disk",
"subject", caCert.Subject.CommonName,
"issuer", caCert.Issuer.CommonName,
"serial", caCert.SerialNumber,
"not_after", caCert.NotAfter,
"is_self_signed", caCert.Issuer.CommonName == caCert.Subject.CommonName)
return nil
}
// generateSelfSignedCA creates an ephemeral self-signed CA for development/demo.
func (c *Connector) generateSelfSignedCA() error {
c.logger.Info("generating self-signed CA (ephemeral mode)", "common_name", c.config.CACommonName)
// Generate CA private key. RSA-2048 has been the historical default
// since the local issuer shipped; preserving the algorithm here is
// part of the Signer-refactor's no-behavior-change guarantee.
caKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("failed to generate CA key: %w", err)
}
// Wrap the freshly-generated key behind the Signer interface so the
// CreateCertificate call below uses the same access pattern as every
// other CA-signing call site (interface-level Public() + Sign()).
// Wrap is infallible for RSA-2048; the err return is propagated for
// completeness against future Algorithm enum changes.
caSigner, err := signer.Wrap(caKey)
if err != nil {
return fmt.Errorf("failed to wrap CA private key as signer: %w", err)
}
// Create CA certificate
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: c.config.CACommonName,
},
NotBefore: time.Now(),
NotAfter: time.Now().AddDate(10, 0, 0), // CA valid for 10 years
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
// Self-sign the CA certificate via the Signer interface. The
// underlying byte sequence is identical to the historical
// (&caKey.PublicKey, caKey) form because Wrap returns a thin
// adapter that delegates Sign and Public to the same crypto.Signer.
caCertBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caSigner.Public(), caSigner)
if err != nil {
return fmt.Errorf("failed to create CA certificate: %w", err)
}
caCert, err := x509.ParseCertificate(caCertBytes)
if err != nil {
return fmt.Errorf("failed to parse CA certificate: %w", err)
}
// Encode CA certificate to PEM
caCertPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: caCertBytes,
})
c.caSigner = caSigner
c.caCert = caCert
c.caCertPEM = string(caCertPEM)
c.logger.Info("self-signed CA initialized",
"serial", caCert.SerialNumber,
"not_after", caCert.NotAfter)
return nil
}
// parsePrivateKey moved to internal/crypto/signer/parse.go as part of the
// Signer abstraction work. The exported wrapper there
// (signer.ParsePrivateKey) is the single source of truth for PEM
// private-key parsing inside certctl. Do not reintroduce a parallel
// implementation here; the loadCAFromDisk path above calls into the
// signer package directly.
// generateCertificate creates an X.509 certificate signed by the local CA.
// It uses the CSR subject and adds any additional SANs from the request.
// If ekus is non-empty, those EKUs are used instead of the default serverAuth+clientAuth.
// If maxTTLSeconds > 0, the certificate validity is capped to that duration.
func (c *Connector) generateCertificate(csr *x509.CertificateRequest, additionalSANs []string, ekus []string, maxTTLSeconds int, mustStaple bool) (*x509.Certificate, string, string, error) {
// Generate random serial number
serialNum, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 159))
if err != nil {
return nil, "", "", fmt.Errorf("failed to generate serial number: %w", err)
}
serial := fmt.Sprintf("%040x", serialNum)
// Collect all SANs
sanSet := make(map[string]bool)
for _, san := range csr.DNSNames {
sanSet[san] = true
}
for _, san := range csr.IPAddresses {
sanSet[san.String()] = true
}
for _, san := range csr.EmailAddresses {
sanSet[san] = true
}
for _, san := range additionalSANs {
sanSet[san] = true
}
var dnsNames []string
var ips []string
var emails []string
for san := range sanSet {
// Try to parse as IP, otherwise treat as DNS or email
if ip := parseIP(san); ip != nil {
ips = append(ips, san)
} else if isEmail(san) {
emails = append(emails, san)
} else {
dnsNames = append(dnsNames, san)
}
}
// Resolve EKUs: use provided list or fall back to default TLS EKUs
resolvedEKUs, keyUsage := resolveEKUsAndKeyUsage(ekus)
// Create certificate template
now := time.Now()
notAfter := now.AddDate(0, 0, c.config.ValidityDays)
// Cap validity to MaxTTLSeconds if profile specifies a maximum
if maxTTLSeconds > 0 {
maxNotAfter := now.Add(time.Duration(maxTTLSeconds) * time.Second)
if maxNotAfter.Before(notAfter) {
notAfter = maxNotAfter
}
}
template := &x509.Certificate{
SerialNumber: serialNum,
Subject: csr.Subject,
NotBefore: now,
NotAfter: notAfter,
KeyUsage: keyUsage,
ExtKeyUsage: resolvedEKUs,
DNSNames: dnsNames,
EmailAddresses: emails,
SubjectKeyId: hashPublicKey(csr.PublicKey),
AuthorityKeyId: c.caCert.SubjectKeyId,
}
// Production hardening II Phase 6: auto-inject the
// id-ce-cRLDistributionPoints extension when configured. crypto/x509
// handles the ASN.1 encoding from the URL slice. Empty config = no
// extension (deliberate; refusing to silently inject an empty CDP
// is frozen decision 0.9).
if len(c.config.CRLDistributionPointURLs) > 0 {
template.CRLDistributionPoints = append(template.CRLDistributionPoints, c.config.CRLDistributionPointURLs...)
}
// Add IP addresses if present
if len(ips) > 0 {
for _, ipStr := range ips {
if ip := parseIP(ipStr); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
}
}
}
// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple
// extension per RFC 7633. When the bound CertificateProfile has
// MustStaple=true, the issued cert carries id-pe-tlsfeature with
// the TLS Feature `status_request` (5). Browsers + modern TLS
// libraries that see this extension fail-closed when OCSP stapling
// is missing — defense against revocation-bypass via OCSP
// blackholing.
if mustStaple {
template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{
Id: oidMustStaple,
Critical: false,
Value: mustStapleExtensionValue,
})
}
// Sign certificate with CA
certBytes, err := x509.CreateCertificate(rand.Reader, template, c.caCert, csr.PublicKey, c.caSigner)
if err != nil {
return nil, "", "", fmt.Errorf("failed to sign certificate: %w", err)
}
// Parse for validation
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, "", "", fmt.Errorf("failed to parse certificate: %w", err)
}
// Encode to PEM
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})
return cert, string(certPEM), serial, nil
}
// parseIP attempts to parse a string as an IP address.
func parseIP(s string) []byte {
if s == "localhost" {
return []byte{127, 0, 0, 1}
}
ip := net.ParseIP(s)
if ip == nil {
return nil
}
// Prefer 4-byte representation for IPv4
if v4 := ip.To4(); v4 != nil {
return v4
}
return ip
}
// isEmail checks if a string looks like an email address.
func isEmail(s string) bool {
for _, c := range s {
if c == '@' {
return true
}
}
return false
}
// ekuNameToX509 maps EKU string names (from domain.ValidEKUs) to x509.ExtKeyUsage constants.
// SCEP RFC 8894 + Intune master bundle Phase 5.6: must-staple extension
// constants per RFC 7633 §6.
//
// id-pe-tlsfeature OID: 1.3.6.1.5.5.7.1.24.
var oidMustStaple = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 24}
// mustStapleExtensionValue is the pre-encoded DER for SEQUENCE OF INTEGER
// containing a single value 5 (the TLS Feature for status_request, RFC
// 7633 §6 referencing IANA TLS ExtensionType registry).
//
// Wire bytes:
//
// 0x30 0x03 -- SEQUENCE, length 3
// 0x02 0x01 0x05 -- INTEGER 5 (status_request)
//
// Pre-encoded as a constant rather than asn1.Marshal'd at runtime: the
// extension value is fixed, byte-stable across Go versions, and tested by
// pinning the exact bytes against RFC 7633 §6.
var mustStapleExtensionValue = []byte{0x30, 0x03, 0x02, 0x01, 0x05}
var ekuNameToX509 = map[string]x509.ExtKeyUsage{
"serverAuth": x509.ExtKeyUsageServerAuth,
"clientAuth": x509.ExtKeyUsageClientAuth,
"codeSigning": x509.ExtKeyUsageCodeSigning,
"emailProtection": x509.ExtKeyUsageEmailProtection,
"timeStamping": x509.ExtKeyUsageTimeStamping,
}
// resolveEKUsAndKeyUsage maps EKU string names to x509.ExtKeyUsage constants and computes
// appropriate KeyUsage flags. If ekus is empty/nil, falls back to default TLS EKUs.
//
// Key usage selection:
// - TLS (serverAuth/clientAuth): DigitalSignature | KeyEncipherment
// - S/MIME (emailProtection): DigitalSignature | ContentCommitment (for non-repudiation)
// - Mixed: union of both
func resolveEKUsAndKeyUsage(ekus []string) ([]x509.ExtKeyUsage, x509.KeyUsage) {
if len(ekus) == 0 {
// Default: TLS server + client
return []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
x509.ExtKeyUsageClientAuth,
}, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
}
var resolved []x509.ExtKeyUsage
hasEmail := false
hasTLS := false
for _, name := range ekus {
if eku, ok := ekuNameToX509[name]; ok {
resolved = append(resolved, eku)
if name == "emailProtection" {
hasEmail = true
}
if name == "serverAuth" || name == "clientAuth" {
hasTLS = true
}
}
}
// If no valid EKUs were resolved, fall back to default
if len(resolved) == 0 {
return []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
x509.ExtKeyUsageClientAuth,
}, x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment
}
// Compute KeyUsage based on EKU mix
keyUsage := x509.KeyUsageDigitalSignature
if hasTLS {
keyUsage |= x509.KeyUsageKeyEncipherment
}
if hasEmail {
keyUsage |= x509.KeyUsageContentCommitment // non-repudiation for S/MIME
}
return resolved, keyUsage
}
// validateCSRUnicode runs the L-012 Unicode safety check across every name
// that will be embedded in the issued certificate's Subject CommonName or
// SubjectAltName extension. It rejects RTL/zero-width/control characters
// and mixed-script (Latin + non-Latin) DNS labels — see
// internal/validation/unicode.go for the full rationale and threat model.
//
// We check both the names that came in via the CSR itself AND any
// additional SANs supplied alongside the issuance request, because either
// surface can be an attacker-controlled vector.
func validateCSRUnicode(csr *x509.CertificateRequest, additionalSANs []string) error {
if err := validation.ValidateUnicodeSafe(csr.Subject.CommonName); err != nil {
return fmt.Errorf("CSR Subject.CommonName rejected: %w", err)
}
for _, name := range csr.DNSNames {
if err := validation.ValidateUnicodeSafe(name); err != nil {
return fmt.Errorf("CSR DNSNames entry %q rejected: %w", name, err)
}
}
for _, email := range csr.EmailAddresses {
if err := validation.ValidateUnicodeSafe(email); err != nil {
return fmt.Errorf("CSR EmailAddresses entry %q rejected: %w", email, err)
}
}
for _, name := range additionalSANs {
if err := validation.ValidateUnicodeSafe(name); err != nil {
return fmt.Errorf("request SANs entry %q rejected: %w", name, err)
}
}
return nil
}
// hashPublicKey generates a subject key identifier from a public key.
//
// Bundle-9 / Audit M-028 (CWE-477 / SA1019): the ECDSA arm previously used
// `elliptic.Marshal(k.Curve, k.X, k.Y)`, which staticcheck SA1019 flags as
// deprecated since Go 1.21 ("for ECDH, use crypto/ecdh"). The replacement
// here uses crypto/ecdh.PublicKey.Bytes(), which produces the IDENTICAL
// uncompressed SEC 1 encoding for the supported curves (P-224, P-256,
// P-384, P-521 — matched in key_encoding_test.go via a byte-identical
// round-trip pin so the migration cannot silently regress the SubjectKeyId
// of every issued certificate).
//
// If the ECDSA key uses a curve not in crypto/ecdh's supported set
// (theoretically possible if an operator loaded a custom CA), we fall back
// to hashing the X+Y coordinates directly via big.Int.Bytes() — that
// produces a different (and stable) SKI for that pathological case rather
// than panicking. The covered-curve path is the one the round-trip pin
// asserts.
func hashPublicKey(pub interface{}) []byte {
h := sha256.New()
switch k := pub.(type) {
case *rsa.PublicKey:
h.Write(k.N.Bytes())
case *ecdsa.PublicKey:
ecdhPub, err := ecdsaToECDH(k)
if err == nil {
h.Write(ecdhPub.Bytes())
} else {
// Unsupported curve — stable fallback. See test
// TestHashPublicKey_ECDSA_RoundTripPin for the supported-curve
// invariant (must match the legacy elliptic.Marshal output).
h.Write(k.X.Bytes())
h.Write(k.Y.Bytes())
}
}
return h.Sum(nil)[:4] // Use first 4 bytes for brevity
}
// ecdsaToECDH converts an ECDSA public key to a crypto/ecdh.PublicKey for
// the supported curves (P-256, P-384, P-521; P-224 is intentionally
// unsupported by crypto/ecdh upstream). Used by hashPublicKey to replace
// the deprecated elliptic.Marshal call.
//
// We dispatch on Curve.Params().Name (a stable string per RFC 5480 / Go
// stdlib) rather than importing crypto/elliptic just for sentinel
// comparisons — keeps the deprecated package out of this file's import
// graph.
func ecdsaToECDH(pub *ecdsa.PublicKey) (*ecdh.PublicKey, error) {
if pub == nil || pub.Curve == nil || pub.X == nil || pub.Y == nil {
return nil, fmt.Errorf("ecdsaToECDH: nil/uninitialized key")
}
var curve ecdh.Curve
switch pub.Curve.Params().Name {
case "P-256":
curve = ecdh.P256()
case "P-384":
curve = ecdh.P384()
case "P-521":
curve = ecdh.P521()
default:
return nil, fmt.Errorf("unsupported curve %q for ecdh conversion", pub.Curve.Params().Name)
}
// Reconstruct the uncompressed SEC 1 encoding, then hand to ecdh which
// validates it back to a public key. This is byte-identical to what
// the deprecated elliptic.Marshal returned for the same input — the
// round-trip pin in key_encoding_test.go enforces that invariant.
byteLen := (pub.Curve.Params().BitSize + 7) / 8
buf := make([]byte, 1+2*byteLen)
buf[0] = 0x04 // uncompressed point marker
xBytes := pub.X.Bytes()
yBytes := pub.Y.Bytes()
copy(buf[1+byteLen-len(xBytes):], xBytes)
copy(buf[1+2*byteLen-len(yBytes):], yBytes)
return curve.NewPublicKey(buf)
}
// GenerateCRL generates a DER-encoded X.509 CRL signed by this local CA.
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
if err := c.ensureCA(ctx); err != nil {
return nil, fmt.Errorf("CA initialization failed: %w", err)
}
now := time.Now()
revokedEntries := make([]x509.RevocationListEntry, 0, len(revokedCerts))
for _, cert := range revokedCerts {
revokedEntries = append(revokedEntries, x509.RevocationListEntry{
SerialNumber: cert.SerialNumber,
RevocationTime: cert.RevokedAt,
ReasonCode: cert.ReasonCode,
})
}
template := &x509.RevocationList{
RevokedCertificateEntries: revokedEntries,
Number: big.NewInt(time.Now().Unix()),
ThisUpdate: now,
NextUpdate: now.Add(24 * time.Hour),
}
crlBytes, err := x509.CreateRevocationList(rand.Reader, template, c.caCert, c.caSigner)
if err != nil {
return nil, fmt.Errorf("failed to create CRL: %w", err)
}
c.logger.Info("CRL generated",
"entries", len(revokedCerts),
"next_update", template.NextUpdate)
return crlBytes, nil
}
// SignOCSPResponse signs an OCSP response for the given certificate.
//
// As of Phase 2 of the CRL/OCSP responder bundle, the signing path is
// no longer hardwired to the CA private key. ensureOCSPResponder
// returns the appropriate cert + signer based on whether the operator
// has wired the dedicated-responder dependencies (SetOCSPResponderRepo
// + SetSignerDriver + SetIssuerID):
//
// - Configured: the response is signed by a dedicated responder cert
// (signed by the CA, has id-pkix-ocsp-nocheck per RFC 6960
// §4.2.2.2.1). Relying parties see the responder cert in the
// response's certificates field; CA-key signing operations stay
// rare (only at responder bootstrap / rotation).
//
// - Unconfigured: falls back to signing with the CA key directly
// (the historical pre-Phase-2 behaviour). Backward-compatible for
// callers that don't wire the responder deps.
//
// The OCSP response template fields (status, serial, thisUpdate,
// nextUpdate, revocation reason) are unchanged across both paths;
// only the signing key + the cert in the response's certificates
// field differ.
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
responderCert, responderSigner, err := c.ensureOCSPResponder(ctx)
if err != nil {
return nil, fmt.Errorf("ensure OCSP responder: %w", err)
}
template := ocsp.Response{
SerialNumber: req.CertSerial,
ThisUpdate: req.ThisUpdate,
NextUpdate: req.NextUpdate,
Certificate: responderCert,
}
switch req.CertStatus {
case 0: // good
template.Status = ocsp.Good
case 1: // revoked
template.Status = ocsp.Revoked
template.RevokedAt = req.RevokedAt
template.RevocationReason = req.RevocationReason
default: // unknown
template.Status = ocsp.Unknown
}
// Production hardening II Phase 1.4: echo the request's nonce in
// the response's singleExtensions field per RFC 6960 §4.4.1.
// The handler walks the inbound request's extensions and populates
// req.Nonce when a well-formed nonce extension is present; we just
// re-marshal it here as the extnValue OCTET STRING.
if len(req.Nonce) > 0 {
nonceExtnValue, err := asn1.Marshal(req.Nonce)
if err != nil {
return nil, fmt.Errorf("marshal OCSP nonce extension: %w", err)
}
template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{
Id: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 2}, // id-pkix-ocsp-nonce
Critical: false, // RFC 6960 §4.4 — nonce is non-critical
Value: nonceExtnValue,
})
}
// ocsp.CreateResponse(issuer, responder, template, signer):
// - issuer: always c.caCert (the CA that issued the cert
// being checked, NOT the responder cert)
// - responder: the responder cert (== c.caCert in the fallback
// path; a dedicated responder cert otherwise)
// - signer: the responder's signing key
respBytes, err := ocsp.CreateResponse(c.caCert, responderCert, template, responderSigner)
if err != nil {
return nil, fmt.Errorf("failed to create OCSP response: %w", err)
}
c.logger.Info("OCSP response signed",
"serial", req.CertSerial,
"status", req.CertStatus,
"responder_cn", responderCert.Subject.CommonName,
"dedicated_responder", responderCert != c.caCert)
return respBytes, nil
}
// GetCACertPEM returns the PEM-encoded CA certificate for this issuer.
// Used by the EST /cacerts endpoint to distribute the CA trust chain.
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
if err := c.ensureCA(ctx); err != nil {
return "", fmt.Errorf("CA initialization failed: %w", err)
}
return c.caCertPEM, nil
}
// GetRenewalInfo returns nil, nil as the Local CA does not support ACME Renewal Information (ARI).
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
return nil, nil
}