Files
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

544 lines
18 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package service
import (
"bytes"
"context"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/certctl-io/certctl/internal/crypto/signer"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
)
// IntermediateCAService manages first-class CA hierarchies for the
// local issuer's tree mode. Rank 8.
//
// Lifecycle: an admin-gated operator calls CreateRoot to register an
// operator-supplied root cert+key as the issuer's active root. They
// then chain CreateChild calls to build out the hierarchy — each
// child's cert is signed by its parent's signer. AssembleChain walks
// the tree at leaf-issuance time to produce the PEM bundle the local
// connector attaches to IssuanceResult.
//
// Defense in depth: NEVER persist CA private key bytes. Every
// IntermediateCA carries a key_driver_id pointing at the signer.Driver
// instance that owns its private key. The default driver is
// signer.FileDriver (matching the historical single-sub-CA mode); HSM-
// backed and KMS-backed drivers (PKCS#11, AWS KMS, Azure Key Vault HSM)
// plug in via the existing seam without touching this service.
//
// Concurrency: every CreateChild that touches a parent reads the
// parent's signer fresh from the driver — no shared in-memory parent-
// signer state. Callers should serialize CreateChild against the same
// parent at the API layer (admin-gated; not a hot path).
type IntermediateCAService struct {
repo repository.IntermediateCARepository
issuerRepo repository.IssuerRepository
signerDriver signer.Driver
auditService *AuditService
metrics *IntermediateCAMetrics
}
// NewIntermediateCAService constructs the service. metrics may be nil
// for tests; auditService should not be nil in production.
func NewIntermediateCAService(
repo repository.IntermediateCARepository,
issuerRepo repository.IssuerRepository,
signerDriver signer.Driver,
auditService *AuditService,
metrics *IntermediateCAMetrics,
) *IntermediateCAService {
return &IntermediateCAService{
repo: repo,
issuerRepo: issuerRepo,
signerDriver: signerDriver,
auditService: auditService,
metrics: metrics,
}
}
// Sentinels for handler-side dispatch via errors.Is.
var (
ErrIntermediateCANotFound = errors.New("intermediate CA not found")
ErrCANotSelfSigned = errors.New("supplied root cert is not self-signed")
ErrCAKeyMismatch = errors.New("supplied CA key does not match the supplied cert")
ErrParentCANotActive = errors.New("parent CA is not in active state")
ErrPathLenExceeded = errors.New("requested path length exceeds parent's PathLenConstraint")
ErrNameConstraintExceeded = errors.New("child name constraints not a subset of parent's")
ErrCAStillHasActiveChildren = errors.New("CA cannot retire: active children still issuing")
ErrInvalidCertPEM = errors.New("invalid cert PEM")
)
// CreateRootOptions are the optional parameters for CreateRoot. The
// rootCert + rootKey are operator-supplied; this struct carries
// per-CA bookkeeping that doesn't live in the cert itself.
type CreateRootOptions struct {
OCSPResponderURL string
Metadata map[string]string
}
// CreateChildOptions are the parameters for CreateChild — everything
// the service needs to build a fresh sub-CA cert under a parent.
type CreateChildOptions struct {
Subject pkix.Name
Algorithm signer.Algorithm
TTL time.Duration // child's validity window
PathLenConstraint *int // RFC 5280 §4.2.1.9; nil = inherit (parent - 1) or no constraint
NameConstraints []domain.NameConstraint
OCSPResponderURL string
Metadata map[string]string
}
// CreateRoot registers an operator-supplied root cert as the issuer's
// active root, paired with a pre-positioned signer.Driver reference
// (file path / HSM slot / KMS resource name) that the operator owns.
// Validates the cert is self-signed (subject == issuer per RFC 5280
// §3.2) AND that the signer.Driver-loadable key at keyDriverID has a
// public key matching the cert's public key (rejects mismatched
// bundles at the operator boundary, not just at signing time).
// Returns the new ica-<slug> ID.
func (s *IntermediateCAService) CreateRoot(ctx context.Context, issuerID, name, decidedBy string,
rootCertPEM []byte, keyDriverID string, opts *CreateRootOptions) (string, error) {
if opts == nil {
opts = &CreateRootOptions{}
}
if keyDriverID == "" {
return "", fmt.Errorf("CreateRoot: keyDriverID required")
}
cert, err := parseCertPEM(rootCertPEM)
if err != nil {
return "", fmt.Errorf("CreateRoot: %w", err)
}
// RFC 5280 §3.2: a root cert is self-signed (subject == issuer +
// signature verifies under the cert's own public key).
if !cert.IsCA {
return "", fmt.Errorf("CreateRoot: %w: cert lacks BasicConstraints CA:TRUE", ErrCANotSelfSigned)
}
if err := cert.CheckSignatureFrom(cert); err != nil {
return "", fmt.Errorf("CreateRoot: %w: %v", ErrCANotSelfSigned, err)
}
// Verify the supplied keyDriverID resolves to a signer whose public
// key matches the cert's public key. Defense-in-depth — catches
// operator wiring errors at registration time rather than at first
// CreateChild attempt.
rootSigner, err := s.signerDriver.Load(ctx, keyDriverID)
if err != nil {
return "", fmt.Errorf("CreateRoot: load key: %w", err)
}
if !publicKeysEqual(rootSigner.Public(), cert.PublicKey) {
return "", ErrCAKeyMismatch
}
ca := &domain.IntermediateCA{
OwningIssuerID: issuerID,
ParentCAID: nil, // root has no parent
Name: name,
Subject: cert.Subject.String(),
State: domain.IntermediateCAStateActive,
CertPEM: string(rootCertPEM),
KeyDriverID: keyDriverID,
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
PathLenConstraint: pathLenFromCert(cert),
NameConstraints: nameConstraintsFromCert(cert),
OCSPResponderURL: opts.OCSPResponderURL,
Metadata: opts.Metadata,
}
if err := s.repo.Create(ctx, ca); err != nil {
return "", fmt.Errorf("CreateRoot: %w", err)
}
s.recordAudit(ctx, decidedBy, domain.ActorTypeUser, "intermediate_ca_root_created", ca, nil)
if s.metrics != nil {
s.metrics.RecordCreate(ca.OwningIssuerID, "root")
}
return ca.ID, nil
}
// CreateChild signs a new sub-CA cert under the given parent.
// Enforces RFC 5280 §4.2.1.9 (PathLenConstraint must not exceed
// parent's) + §4.2.1.10 (NameConstraints must be a subset of
// parent's). Generates the child's key via the signer.Driver; signs
// the cert via the parent's signer (loaded by the parent's
// KeyDriverID).
func (s *IntermediateCAService) CreateChild(ctx context.Context, parentCAID, name, decidedBy string,
opts *CreateChildOptions) (string, error) {
if opts == nil {
return "", fmt.Errorf("CreateChild: opts required")
}
parent, err := s.repo.Get(ctx, parentCAID)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return "", ErrIntermediateCANotFound
}
return "", fmt.Errorf("CreateChild: get parent: %w", err)
}
if parent.State != domain.IntermediateCAStateActive {
return "", ErrParentCANotActive
}
parentCert, err := parseCertPEM([]byte(parent.CertPEM))
if err != nil {
return "", fmt.Errorf("CreateChild: parent cert: %w", err)
}
// RFC 5280 §4.2.1.9 enforcement.
childPathLen := opts.PathLenConstraint
if parent.PathLenConstraint != nil {
if childPathLen != nil && *childPathLen >= *parent.PathLenConstraint {
return "", ErrPathLenExceeded
}
// If unset, default to parent - 1 (or 0 if parent is 0).
if childPathLen == nil {
v := *parent.PathLenConstraint - 1
if v < 0 {
v = 0
}
childPathLen = &v
}
}
// RFC 5280 §4.2.1.10 enforcement: child's permitted ⊆ parent's
// permitted; child's excluded ⊇ parent's excluded.
if err := validateNameConstraintsSubset(parent.NameConstraints, opts.NameConstraints); err != nil {
return "", err
}
// Generate the child's key via the signer.Driver.
childSigner, keyDriverID, err := s.signerDriver.Generate(ctx, opts.Algorithm)
if err != nil {
return "", fmt.Errorf("CreateChild: generate key: %w", err)
}
// Load the parent's signer to sign the child's cert.
parentSigner, err := s.signerDriver.Load(ctx, parent.KeyDriverID)
if err != nil {
return "", fmt.Errorf("CreateChild: load parent signer: %w", err)
}
// Build the child cert template.
now := time.Now().UTC()
ttl := opts.TTL
if ttl <= 0 {
ttl = 5 * 365 * 24 * time.Hour // 5y default for sub-CAs
}
notBefore := now
notAfter := now.Add(ttl)
if notAfter.After(parentCert.NotAfter) {
// Child must not outlive parent (RFC 5280 §4.1.2.5; cert chain
// breaks at parent's expiry regardless).
notAfter = parentCert.NotAfter
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return "", fmt.Errorf("CreateChild: serial: %w", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: opts.Subject,
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
if childPathLen != nil {
template.MaxPathLen = *childPathLen
template.MaxPathLenZero = (*childPathLen == 0)
}
if len(opts.NameConstraints) > 0 {
var permitted, excluded []string
for _, nc := range opts.NameConstraints {
permitted = append(permitted, nc.Permitted...)
excluded = append(excluded, nc.Excluded...)
}
template.PermittedDNSDomains = permitted
template.ExcludedDNSDomains = excluded
template.PermittedDNSDomainsCritical = true
}
if opts.OCSPResponderURL != "" {
template.OCSPServer = []string{opts.OCSPResponderURL}
}
childDER, err := x509.CreateCertificate(rand.Reader, template, parentCert, childSigner.Public(), parentSigner)
if err != nil {
return "", fmt.Errorf("CreateChild: sign cert: %w", err)
}
childPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: childDER})
parentID := parent.ID
ca := &domain.IntermediateCA{
OwningIssuerID: parent.OwningIssuerID,
ParentCAID: &parentID,
Name: name,
Subject: opts.Subject.String(),
State: domain.IntermediateCAStateActive,
CertPEM: string(childPEM),
KeyDriverID: keyDriverID,
NotBefore: notBefore,
NotAfter: notAfter,
PathLenConstraint: childPathLen,
NameConstraints: opts.NameConstraints,
OCSPResponderURL: opts.OCSPResponderURL,
Metadata: opts.Metadata,
}
if err := s.repo.Create(ctx, ca); err != nil {
return "", fmt.Errorf("CreateChild: create row: %w", err)
}
s.recordAudit(ctx, decidedBy, domain.ActorTypeUser, "intermediate_ca_child_created", ca,
map[string]interface{}{"parent_ca_id": parent.ID})
if s.metrics != nil {
s.metrics.RecordCreate(parent.OwningIssuerID, "child")
}
return ca.ID, nil
}
// Retire transitions a CA's state. First call: active → retiring.
// Second call (with confirm=true): retiring → retired. Refuses retired
// transition if active children still exist (drain-first semantics).
func (s *IntermediateCAService) Retire(ctx context.Context, caID, decidedBy, note string, confirm bool) error {
ca, err := s.repo.Get(ctx, caID)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return ErrIntermediateCANotFound
}
return fmt.Errorf("Retire: get: %w", err)
}
var newState domain.IntermediateCAState
switch ca.State {
case domain.IntermediateCAStateActive:
newState = domain.IntermediateCAStateRetiring
case domain.IntermediateCAStateRetiring:
if !confirm {
return fmt.Errorf("Retire: already retiring; pass confirm=true to terminalize")
}
// Verify no active children before terminalizing.
children, err := s.repo.ListChildren(ctx, caID)
if err != nil {
return fmt.Errorf("Retire: list children: %w", err)
}
for _, ch := range children {
if ch.State == domain.IntermediateCAStateActive {
return ErrCAStillHasActiveChildren
}
}
newState = domain.IntermediateCAStateRetired
default:
return fmt.Errorf("Retire: already retired")
}
if err := s.repo.UpdateState(ctx, caID, newState); err != nil {
return fmt.Errorf("Retire: update state: %w", err)
}
s.recordAudit(ctx, decidedBy, domain.ActorTypeUser,
"intermediate_ca_"+string(newState), ca,
map[string]interface{}{"note": note})
if s.metrics != nil {
s.metrics.RecordRetire(ca.OwningIssuerID, string(newState))
}
return nil
}
// Get returns a single CA by ID.
func (s *IntermediateCAService) Get(ctx context.Context, id string) (*domain.IntermediateCA, error) {
ca, err := s.repo.Get(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, ErrIntermediateCANotFound
}
return nil, err
}
return ca, nil
}
// LoadHierarchy returns the flat list for an issuer; caller renders the
// tree from parent_ca_id.
func (s *IntermediateCAService) LoadHierarchy(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) {
return s.repo.ListByIssuer(ctx, issuerID)
}
// AssembleChain walks the ancestry of leafCAID and returns the PEM
// bundle (leaf CA included, ordered leaf → root). The local connector
// uses this at issue time to populate IssuanceResult.ChainPEM. The
// caller of IssueCertificate prepends the just-issued leaf cert to
// this bundle.
func (s *IntermediateCAService) AssembleChain(ctx context.Context, leafCAID string) (string, error) {
chain, err := s.repo.WalkAncestry(ctx, leafCAID)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return "", ErrIntermediateCANotFound
}
return "", fmt.Errorf("AssembleChain: %w", err)
}
var b strings.Builder
for _, ca := range chain {
b.WriteString(ca.CertPEM)
if !strings.HasSuffix(ca.CertPEM, "\n") {
b.WriteString("\n")
}
}
return b.String(), nil
}
// publicKeysEqual reports whether two crypto.PublicKey values are
// byte-identical when serialized via PKIX. Cheaper alternative to
// reflect.DeepEqual that survives algorithm-specific oddities (RSA
// key Equal method, ECDSA curve pointer compare).
func publicKeysEqual(a, b interface{}) bool {
aBytes, err := x509.MarshalPKIXPublicKey(a)
if err != nil {
return false
}
bBytes, err := x509.MarshalPKIXPublicKey(b)
if err != nil {
return false
}
return bytes.Equal(aBytes, bBytes)
}
// validateNameConstraintsSubset enforces RFC 5280 §4.2.1.10. The
// child's permitted set must be a subset of the parent's permitted
// set (a child cannot widen permitted scope); the child's excluded
// set must be a superset of the parent's excluded set (a child
// cannot remove an excluded subtree).
func validateNameConstraintsSubset(parent, child []domain.NameConstraint) error {
flatParentPermitted := flattenPermitted(parent)
flatParentExcluded := flattenExcluded(parent)
flatChildPermitted := flattenPermitted(child)
flatChildExcluded := flattenExcluded(child)
if len(flatParentPermitted) > 0 {
// If parent has a non-empty permitted set, every child permitted
// MUST belong to (or be a subdomain of) some parent permitted
// entry.
for _, p := range flatChildPermitted {
if !isPermittedUnderParent(p, flatParentPermitted) {
return fmt.Errorf("%w: child permitted %q not under parent permitted set", ErrNameConstraintExceeded, p)
}
}
}
// Excluded: every parent-excluded entry MUST be present (or covered)
// in the child's excluded set.
for _, pe := range flatParentExcluded {
if !isExcludedByChild(pe, flatChildExcluded) {
return fmt.Errorf("%w: parent excluded %q not preserved in child", ErrNameConstraintExceeded, pe)
}
}
return nil
}
func flattenPermitted(ncs []domain.NameConstraint) []string {
var out []string
for _, n := range ncs {
out = append(out, n.Permitted...)
}
return out
}
func flattenExcluded(ncs []domain.NameConstraint) []string {
var out []string
for _, n := range ncs {
out = append(out, n.Excluded...)
}
return out
}
// isPermittedUnderParent reports whether candidate is the parent's
// permitted entry exactly OR a subdomain of one.
func isPermittedUnderParent(candidate string, parentSet []string) bool {
for _, p := range parentSet {
if candidate == p || strings.HasSuffix(candidate, "."+p) {
return true
}
}
return false
}
// isExcludedByChild reports whether parentExcluded is in child's
// excluded set (exactly OR via a wider exclusion in the child).
func isExcludedByChild(parentExcluded string, childSet []string) bool {
for _, c := range childSet {
if parentExcluded == c || strings.HasSuffix(parentExcluded, "."+c) {
return true
}
}
return false
}
func parseCertPEM(certPEM []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, fmt.Errorf("%w: no PEM block in cert", ErrInvalidCertPEM)
}
return x509.ParseCertificate(block.Bytes)
}
func pathLenFromCert(cert *x509.Certificate) *int {
if !cert.BasicConstraintsValid {
return nil
}
if cert.MaxPathLen == 0 && !cert.MaxPathLenZero {
// Go's x509 uses MaxPathLen=0 + MaxPathLenZero=false to mean "no constraint";
// MaxPathLen=0 + MaxPathLenZero=true to mean "constraint of 0".
return nil
}
v := cert.MaxPathLen
return &v
}
func nameConstraintsFromCert(cert *x509.Certificate) []domain.NameConstraint {
if len(cert.PermittedDNSDomains) == 0 && len(cert.ExcludedDNSDomains) == 0 {
return nil
}
return []domain.NameConstraint{{
Permitted: append([]string(nil), cert.PermittedDNSDomains...),
Excluded: append([]string(nil), cert.ExcludedDNSDomains...),
}}
}
// recordAudit is the shared audit-emission helper.
func (s *IntermediateCAService) recordAudit(ctx context.Context, actor string, actorType domain.ActorType,
action string, ca *domain.IntermediateCA, extra map[string]interface{}) {
if s.auditService == nil || ca == nil {
return
}
details := map[string]interface{}{
"intermediate_ca_id": ca.ID,
"owning_issuer_id": ca.OwningIssuerID,
"name": ca.Name,
"subject": ca.Subject,
"state": string(ca.State),
"key_driver_id": ca.KeyDriverID,
"not_before": ca.NotBefore.Format(time.RFC3339),
"not_after": ca.NotAfter.Format(time.RFC3339),
}
if ca.ParentCAID != nil {
details["parent_ca_id"] = *ca.ParentCAID
}
for k, v := range extra {
details[k] = v
}
_ = s.auditService.RecordEvent(ctx, actor, actorType, action,
"intermediate_ca", ca.ID, details)
}