mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:51:30 +00:00
service: IntermediateCAService + IntermediateCAMetrics + RFC 5280 enforcement
Rank 8 of the 2026-05-03 deep-research deliverable, commit 2 of 5.
Service-layer wiring for first-class N-level CA hierarchy management.
The connector rewrite that activates this surface lands in commit 3.
Files added:
internal/service/intermediate_ca.go — IntermediateCAService
with 6 methods:
CreateRoot:
registers operator-
supplied root cert+key
reference. Validates
RFC 5280 §3.2 self-
signed (subject ==
issuer + signature
verifies). Cross-
checks the supplied
keyDriverID resolves
to a signer whose
public key matches
the cert (rejects
mismatched bundles
at registration
time, not at first
CreateChild — the
ErrCAKeyMismatch
sentinel).
CreateChild:
generates child key
via signer.Driver,
signs the cert via
the parent's signer.
Enforces RFC 5280
§4.2.1.9 (path-len
tightening) +
§4.2.1.10
(NameConstraints
subset semantics) at
service layer fail-
closed. Defaults
child path-len to
parent-1 when
unset; caps child
validity at parent's
not_after (RFC 5280
§4.1.2.5).
Retire: two-phase
drain — first call
active → retiring,
second call (with
confirm=true)
retiring → retired.
Refuses retired
transition if active
children still exist
(the
ErrCAStillHasActiveChildren
sentinel — drain-
first semantics).
Get / LoadHierarchy:
thin repo wrappers.
AssembleChain: walks
WalkAncestry (the
recursive CTE
shipped in commit 1)
and returns the
leaf-to-root PEM
bundle for the
local connector to
attach to
IssuanceResult.
internal/service/intermediate_ca_metrics.go — IntermediateCAMetrics:
per-(issuer_id, kind)
counter, mirrors the
ApprovalMetrics +
ExpiryAlertMetrics
pattern. RecordCreate
(root/child) +
RecordRetire
(retiring/retired).
SnapshotIntermediateCA
for the Prometheus
exposer.
Defense in depth retained:
- NEVER persist CA private key bytes in the row. KeyDriverID is the
only key reference; signer.Driver.Load resolves it at signing time.
- The Driver interface has 3 methods (Load/Generate/Name) — no
Import surface. CreateRoot accepts a pre-positioned KeyDriverID
rather than raw key bytes; the operator owns where the root key
physically lives. Future PKCS11Driver / CloudKMSDriver close the
file-on-disk leg without touching this service.
Verified locally:
gofmt: clean.
go vet ./internal/service/...: exit 0.
go build ./internal/service/...: exit 0.
Deferred to commit 2.5 (or fold into commit 3, operator's call):
- 9 service-level tests including:
* TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned
* TestIntermediateCA_CreateRoot_RejectsNonSelfSigned
* TestIntermediateCA_CreateRoot_RejectsKeyMismatch
* TestIntermediateCA_CreateChild_PathLenTighteningEnforced
* TestIntermediateCA_CreateChild_NameConstraintsSubset
* TestIntermediateCA_AssembleChain_4DeepHierarchy ← LOAD-BEARING
* TestIntermediateCA_Retire_RefusesIfActiveChildren
* TestIntermediateCA_Retire_TwoPhaseConfirm
* TestIntermediateCA_MetricsRecordedPerOutcome
Test setup needs: in-memory IntermediateCARepository fake +
signer.MemoryDriver (already exists) + helper to generate test root
cert+key. Fake repo's WalkAncestry implementation needs to mirror
the recursive-CTE semantics for the AssembleChain pin to be
meaningful. Total ~500 lines of test code; non-trivial setup.
Out of scope of THIS commit (commits 3-5):
- Local connector rewrite + byte-equivalence pin
(TestLocal_HierarchyMode_SingleVsTree_ByteIdentical).
- 4 admin-gated handler endpoints + OpenAPI extension.
- web/src/pages/IssuerHierarchyPage.tsx.
- docs/intermediate-ca-hierarchy.md sysadmin runbook.
- cmd/server/main.go wiring.
Reference: cowork/rank-8-intermediate-ca-hierarchy-prompt.md.
This commit is contained in:
@@ -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-<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)
|
||||
}
|
||||
@@ -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_<state>" — 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
|
||||
}
|
||||
Reference in New Issue
Block a user