mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 17:18:51 +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,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