mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:41:36 +00:00
fb54ebcb62
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.
105 lines
2.7 KiB
Go
105 lines
2.7 KiB
Go
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
|
|
}
|