mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 15:08:56 +00:00
EST RFC 7030 hardening master bundle Phases 2-4: end-to-end mTLS sibling
route + RFC 9266 channel binding + HTTP Basic enrollment-password +
per-source-IP failed-auth limit + per-(CN, sourceIP) sliding-window cap.
Two new shared packages so EST + Intune share infrastructure:
- internal/cms/ — RFC 9266 tls-exporter extractor (ExtractTLSExporter
with stdlib-panic recovery for synthetic ConnectionStates) +
CSR-side channel-binding parser via raw TBSCertificationRequestInfo
walk (the stdlib's csr.Attributes can't represent the OCTET STRING
binding value), VerifyChannelBinding composite, EmbedChannel-
BindingAttribute fixture helper, typed sentinel errors for missing
/ mismatch / not-TLS-1.3 mapped to HTTP 400 / 409 / 426 in handler.
- internal/trustanchor/ — extracted from scep/intune/trust_anchor*.go
so the EST mTLS sibling route + Intune dispatcher share the same
SIGHUP-reloadable PEM bundle primitive. intune.TrustAnchorHolder
is now `= trustanchor.Holder` (type alias) + NewTrustAnchorHolder =
trustanchor.New (function alias) — every existing call site compiles
unchanged. Intune's LoadTrustAnchor is a thin wrapper over
trustanchor.LoadBundle. White-box tests moved to the new package.
- internal/ratelimit/ — extracted from scep/intune/rate_limit.go (this
was Phase 4.1, in the same bundle). intune.PerDeviceRateLimiter
is now a thin wrapper preserving the (subject, issuer)→key
composition; EST handler reaches for SlidingWindowLimiter directly.
ESTHandler grew six optional fields wired by per-profile setters
(SetMTLSTrust / SetChannelBindingRequired / SetEnrollmentPassword /
SetSourceIPRateLimiter / SetPerPrincipalRateLimiter / SetLabelForLog)
plus four new mTLS-route methods (CACertsMTLS / SimpleEnrollMTLS /
SimpleReEnrollMTLS / CSRAttrsMTLS); shared internal pipeline
handleEnrollOrReEnroll(reEnroll, viaMTLS) keeps the auth/binding/
rate-limit gates DRY. New router method RegisterESTMTLSHandlers
registers /.well-known/est-mtls/<PathID>/{cacerts,simpleenroll,
simplereenroll,csrattrs}; AuthExemptDispatchPrefixes extends the
no-auth chain to /.well-known/est-mtls.
cmd/server/main.go's EST loop wires per-profile mTLS holder +
channel-binding policy + per-principal limiter + (when EnrollmentPassword
non-empty) Basic + source-IP limiter; new preflightESTMTLSClientCATrust-
Bundle returns *trustanchor.Holder so SIGHUP rotates the EST mTLS
bundle live without restart. SCEP + EST mTLS profiles now share a
single union mtlsUnionPoolForTLS passed to buildServerTLSConfigWithMTLS
(replaces the protocol-specific scepMTLSUnionPoolForTLS); per-handler
re-verify enforces "cert must chain to THIS profile's bundle" so
cross-protocol bleed is blocked at the application layer even though
the TLS layer trusts certs from either pool's union.
Phase 3.3 source-IP failed-Basic limiter defaults: 10 attempts / 1h
/ 50k tracked IPs (no env var; tunable in a follow-up). Phase 4.2
per-principal limiter cap from CERTCTL_EST_PROFILE_<NAME>_RATE_
LIMIT_PER_PRINCIPAL_24H (existing field, Phase 1 shipped).
New tests:
- internal/cms/channelbinding_test.go: extractor + CSR-side parser +
composite + TLS-1.3 round-trip end-to-end + EmbedChannelBinding-
Attribute round-trip
- internal/trustanchor/holder_test.go: parseBundlePEM white-box +
LoadBundle + Holder Get/Pool/SetLabelForLog/Reload-happy/
Reload-keeps-old-on-failure/Reload-keeps-old-on-expired/
WatchSIGHUP-reloads-pool/WatchSIGHUP-stop-clean
- internal/api/handler/est_hardening_test.go: 16 named cases covering
mTLS no-trust-pool 500 + no-cert 401 + cross-profile cert 401 +
happy-path 200 + CACertsMTLS auth gate + CSRAttrsMTLS auth gate +
channel-binding required-absent-rejected + not-required-absent-
allowed + writeChannelBindingError mapping + Basic no-header 401
+ Basic wrong-password 401 + Basic correct-200 + Basic-no-password
no-gate + per-IP failed-attempt lockout 429 + per-principal
blocks-after-cap + different-principals-independent + no-limiter-
unbounded.
Pre-commit verification (sandbox): gofmt clean, go vet clean
(excluding repository/postgres which the sandbox can't build —
disk-space testcontainers download), staticcheck clean for
cms/trustanchor/api/handler/api/router/scep/intune/ratelimit/
cmd/server, go test -short -count=1 green for cms/trustanchor/
api/handler/api/router/scep/intune/ratelimit/service. G-3
docs-drift guard reproduced locally clean (Phase 1 already
documented every new env var; Phases 2-4 added zero new env vars).
This commit is contained in:
@@ -1,143 +1,58 @@
|
||||
package intune
|
||||
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 8.5 (originally) +
|
||||
// EST RFC 7030 hardening master bundle Phase 2.1 (extraction).
|
||||
//
|
||||
// TrustAnchorHolder + NewTrustAnchorHolder were extracted to
|
||||
// internal/trustanchor.Holder + trustanchor.New so the EST mTLS sibling
|
||||
// route (Phase 2 of the EST hardening bundle) and the Intune dispatcher
|
||||
// can share the same SIGHUP-reloadable PEM bundle primitive. A single
|
||||
// SIGHUP now rotates: server TLS cert (cmd/server/tls.go), every Intune
|
||||
// trust anchor (this package's existing wiring), AND every EST mTLS
|
||||
// per-profile client-CA bundle (the new sibling route) — exactly the
|
||||
// design contract documented in the trustanchor package doc.
|
||||
//
|
||||
// The aliases below preserve every existing intune call site unchanged:
|
||||
// - cmd/server/main.go declares `intuneTrustHolders []*intune.TrustAnchorHolder`
|
||||
// + invokes `intune.NewTrustAnchorHolder(path, logger)`
|
||||
// - internal/service/scep.go's SCEPService struct field
|
||||
// `intuneTrust *intune.TrustAnchorHolder` (the type alias keeps this
|
||||
// pointer-compatible with the original)
|
||||
// - internal/scep/intune/trust_anchor_holder_test.go + the e2e tests
|
||||
// that construct a holder via NewTrustAnchorHolder
|
||||
//
|
||||
// New callers SHOULD import internal/trustanchor directly — the
|
||||
// trustanchor.Holder + trustanchor.New are the modern API. The intune
|
||||
// aliases are preserved indefinitely for back-compat (no deprecation
|
||||
// timeline; the cost of the two-line shim is trivial).
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"github.com/shankar0123/certctl/internal/trustanchor"
|
||||
)
|
||||
|
||||
// TrustAnchorHolder is the SIGHUP-reloadable wrapper around a per-profile
|
||||
// Intune Connector trust anchor pool.
|
||||
//
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 8.5.
|
||||
//
|
||||
// Mirrors the shape established by `cmd/server/tls.go::certHolder` for the
|
||||
// server TLS cert: an RWMutex-guarded pool, a Get accessor that's safe for
|
||||
// concurrent callers from the request path, a Reload that re-reads the file
|
||||
// and atomically swaps the slice on success (failure leaves the OLD pool in
|
||||
// place so a bad reload doesn't take Intune enrollment down), and a
|
||||
// watchSIGHUP goroutine that responds to the same SIGHUP the operator uses
|
||||
// to rotate the server TLS cert.
|
||||
//
|
||||
// Why SIGHUP specifically (vs fsnotify or a polling loop): SIGHUP is the
|
||||
// repo-established convention (see cmd/server/tls.go). fsnotify would add a
|
||||
// new direct dep + complicate the cleanup story. The operator's Connector-
|
||||
// rotation script writes the new PEM bundle then sends SIGHUP — the same
|
||||
// signal that already rotates the server TLS cert — and both swap atomically.
|
||||
//
|
||||
// Concurrency contract:
|
||||
// - Get returns the pool slice header by value; the slice itself is
|
||||
// immutable per-snapshot (Reload swaps a fresh slice rather than
|
||||
// mutating the existing one). Callers may iterate the returned slice
|
||||
// without holding any lock.
|
||||
// - Reload acquires a write lock briefly for the swap. Concurrent Get
|
||||
// calls block only for that swap window (microseconds).
|
||||
// - watchSIGHUP runs at most one Reload at a time per holder.
|
||||
type TrustAnchorHolder struct {
|
||||
mu sync.RWMutex
|
||||
certs []*x509.Certificate
|
||||
path string
|
||||
logger *slog.Logger
|
||||
}
|
||||
// Aliased to trustanchor.Holder (extracted in EST RFC 7030 hardening
|
||||
// Phase 2.1) so the EST mTLS sibling route + the Intune dispatcher share
|
||||
// the same primitive. Existing callers compile unchanged because Go type
|
||||
// aliases are pointer-compatible.
|
||||
type TrustAnchorHolder = trustanchor.Holder
|
||||
|
||||
// NewTrustAnchorHolder loads the trust bundle and returns a holder. Returns
|
||||
// the same fail-loud error LoadTrustAnchor does on initial load — the
|
||||
// startup gate at cmd/server/main.go is supposed to refuse boot when this
|
||||
// fails. Subsequent Reload errors are non-fatal (logged + old pool retained).
|
||||
// NewTrustAnchorHolder loads the trust bundle and returns a holder.
|
||||
// Aliased to trustanchor.New (extracted in EST RFC 7030 hardening
|
||||
// Phase 2.1). Returns the same fail-loud error LoadTrustAnchor does on
|
||||
// initial load — the startup gate at cmd/server/main.go is supposed to
|
||||
// refuse boot when this fails. Subsequent Reload errors are non-fatal
|
||||
// (logged + old pool retained).
|
||||
//
|
||||
// The logger is required (never nil); the caller passes a per-profile
|
||||
// scoped logger so SIGHUP-reload events show the PathID for triage.
|
||||
func NewTrustAnchorHolder(path string, logger *slog.Logger) (*TrustAnchorHolder, error) {
|
||||
if logger == nil {
|
||||
return nil, errors.New("intune: TrustAnchorHolder requires a non-nil logger")
|
||||
}
|
||||
certs, err := LoadTrustAnchor(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TrustAnchorHolder{
|
||||
certs: certs,
|
||||
path: path,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get returns the current trust anchor pool. Safe for concurrent callers;
|
||||
// the slice header is returned by value and the underlying slice is
|
||||
// immutable per-snapshot (Reload swaps a fresh slice, doesn't mutate in
|
||||
// place — see Reload).
|
||||
func (h *TrustAnchorHolder) Get() []*x509.Certificate {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.certs
|
||||
}
|
||||
|
||||
// Path returns the on-disk path the holder reloads from. Useful for
|
||||
// observability (admin endpoints, log lines) without exposing the cert
|
||||
// pool itself.
|
||||
func (h *TrustAnchorHolder) Path() string {
|
||||
return h.path
|
||||
}
|
||||
|
||||
// Reload re-reads the trust anchor file at h.path and atomically swaps the
|
||||
// pool. Returns the parse error if the new file is invalid; the OLD pool
|
||||
// stays in place so a bad reload doesn't take Intune enrollment down.
|
||||
//
|
||||
// Same fail-safe pattern as cmd/server/tls.go::(*certHolder).Reload — a
|
||||
// rotation that writes a half-file (operator overwrites the bundle while
|
||||
// only some of the new certs are in it) would otherwise crash the
|
||||
// service mid-rotation. Logging + retaining the old pool gives the
|
||||
// operator a bounded window to fix and re-SIGHUP.
|
||||
func (h *TrustAnchorHolder) Reload() error {
|
||||
certs, err := LoadTrustAnchor(h.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.certs = certs
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WatchSIGHUP installs a signal handler that calls Reload on each SIGHUP.
|
||||
// The returned stop function closes the internal done channel and stops
|
||||
// signal delivery so the goroutine can exit cleanly during shutdown.
|
||||
//
|
||||
// Errors from Reload are logged but do not terminate the watcher — the
|
||||
// operator can fix the files and send another SIGHUP. Mirrors the
|
||||
// (*certHolder).watchSIGHUP contract exactly.
|
||||
//
|
||||
// Multiple holders can coexist: each registers its own goroutine on the
|
||||
// same SIGHUP signal. signal.Notify multicasts to every registered
|
||||
// channel, so a single SIGHUP reloads every per-profile Intune trust
|
||||
// anchor PLUS the server TLS cert in one operator action — exactly the
|
||||
// design requirement (one SIGHUP rotates everything).
|
||||
func (h *TrustAnchorHolder) WatchSIGHUP() (stop func()) {
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGHUP)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
if err := h.Reload(); err != nil {
|
||||
h.logger.Error("Intune trust anchor reload failed; continuing with previous pool",
|
||||
"error", err,
|
||||
"path", h.path)
|
||||
continue
|
||||
}
|
||||
h.logger.Info("Intune trust anchor reloaded via SIGHUP",
|
||||
"path", h.path,
|
||||
"certs_loaded", len(h.Get()))
|
||||
case <-done:
|
||||
signal.Stop(ch)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
// Note: the original intune.NewTrustAnchorHolder set the holder's
|
||||
// internal log label to "Intune trust anchor"; the extracted
|
||||
// trustanchor.New defaults to "trust anchor". Existing intune callers
|
||||
// that need the original label should call .SetLabelForLog("intune
|
||||
// trust anchor (PathID=…)") on the returned holder. cmd/server/main.go
|
||||
// does this in the per-profile Intune startup loop.
|
||||
var NewTrustAnchorHolder = trustanchor.New
|
||||
|
||||
Reference in New Issue
Block a user