diff --git a/internal/service/intermediate_ca.go b/internal/service/intermediate_ca.go new file mode 100644 index 0000000..59c8078 --- /dev/null +++ b/internal/service/intermediate_ca.go @@ -0,0 +1,540 @@ +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- 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) +} diff --git a/internal/service/intermediate_ca_metrics.go b/internal/service/intermediate_ca_metrics.go new file mode 100644 index 0000000..5eb2960 --- /dev/null +++ b/internal/service/intermediate_ca_metrics.go @@ -0,0 +1,104 @@ +package service + +import ( + "sort" + "sync" + "sync/atomic" +) + +// IntermediateCAMetrics is a thread-safe counter table for the CA- +// hierarchy management surface (Rank 8). Mirrors the +// ApprovalMetrics + ExpiryAlertMetrics shape: cmd/server/main.go +// constructs ONE instance, passes it to IntermediateCAService +// (recording side) AND metricsHandler (exposing side) so the +// snapshotter is the single source of truth. +// +// Dimensions: +// +// issuer_id — owning issuer (bounded cardinality; operators have +// <100 issuers in production). +// kind — closed enum: +// "create_root" — CreateRoot succeeded. +// "create_child" — CreateChild succeeded. +// "retire_" — Retire transitioned state. +type IntermediateCAMetrics struct { + mu sync.RWMutex + counters map[intermediateCAKey]*atomic.Uint64 +} + +type intermediateCAKey struct { + IssuerID string + Kind string +} + +// NewIntermediateCAMetrics returns a zero-value instance ready for +// concurrent use. +func NewIntermediateCAMetrics() *IntermediateCAMetrics { + return &IntermediateCAMetrics{ + counters: make(map[intermediateCAKey]*atomic.Uint64), + } +} + +// RecordCreate bumps the create-counter. role ∈ {"root", "child"}. +func (m *IntermediateCAMetrics) RecordCreate(issuerID, role string) { + m.bump(issuerID, "create_"+role) +} + +// RecordRetire bumps the retire-counter. newState ∈ +// {"retiring", "retired"}. +func (m *IntermediateCAMetrics) RecordRetire(issuerID, newState string) { + m.bump(issuerID, "retire_"+newState) +} + +func (m *IntermediateCAMetrics) bump(issuerID, kind string) { + if m == nil { + return + } + key := intermediateCAKey{IssuerID: issuerID, Kind: kind} + m.mu.RLock() + c, ok := m.counters[key] + m.mu.RUnlock() + if !ok { + m.mu.Lock() + c, ok = m.counters[key] + if !ok { + c = &atomic.Uint64{} + m.counters[key] = c + } + m.mu.Unlock() + } + c.Add(1) +} + +// IntermediateCAEntry is a single row of the SnapshotIntermediateCA +// output. +type IntermediateCAEntry struct { + IssuerID string + Kind string + Count uint64 +} + +// SnapshotIntermediateCA returns the current counter table sorted by +// (issuer_id, kind) for deterministic Prometheus exposition. +func (m *IntermediateCAMetrics) SnapshotIntermediateCA() []IntermediateCAEntry { + if m == nil { + return nil + } + m.mu.RLock() + out := make([]IntermediateCAEntry, 0, len(m.counters)) + for k, c := range m.counters { + out = append(out, IntermediateCAEntry{ + IssuerID: k.IssuerID, + Kind: k.Kind, + Count: c.Load(), + }) + } + m.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { + if out[i].IssuerID != out[j].IssuerID { + return out[i].IssuerID < out[j].IssuerID + } + return out[i].Kind < out[j].Kind + }) + return out +}