// 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 }