mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:41:30 +00:00
a12a437664
SCEP RFC 8894 + Intune master bundle — Phase 6.5 of 14 (opt-in,
enterprise-procurement-checkbox).
Closes the procurement-team objection that 'shared password
authentication' is a checkbox-fail regardless of how strong the
password is. The clean answer: a sibling route that adds client-cert
auth at the handler layer AND keeps the challenge password (defense in
depth, not replacement). Devices present a bootstrap cert from a
trusted CA (e.g. a manufacturing-time cert), then SCEP-enroll for
their long-lived cert. Same model Apple's MDM and Cisco's BRSKI use.
internal/config/config.go
* SCEPProfileConfig gains MTLSEnabled bool + MTLSClientCATrustBundlePath
string. Indexed env-var loader reads
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED +
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH.
* Validate() refuses MTLSEnabled=true with empty bundle path —
structural defense in depth ahead of the file-content preflight.
cmd/server/main.go
* preflightSCEPMTLSTrustBundle: file existence + PEM parse + ≥1
CERTIFICATE block + non-expired check. Returns the parsed
*x509.CertPool ready to inject into the per-profile SCEPHandler.
Failures os.Exit(1) with the offending PathID in the structured log.
* SCEP startup loop walks each profile; when MTLSEnabled, runs
preflight, builds the per-profile pool, contributes the bundle's
certs to the union pool that backs the TLS-layer
VerifyClientCertIfGiven, clones the SCEPHandler with
SetMTLSTrustPool, and registers the parallel sibling route via
apiRouter.RegisterSCEPMTLSHandlers.
* Union pool published to outer scope as scepMTLSUnionPoolForTLS;
passed to buildServerTLSConfigWithMTLS so the listener serves both
/scep[/<pathID>] (no client cert) and /scep-mtls/<pathID>
(cert required at handler layer) on the same socket.
* Final-handler dispatch gains /scep-mtls + /scep-mtls/* prefix
routing through the no-auth chain (auth boundary is the client
cert + challenge password, NOT a Bearer token).
cmd/server/tls.go
* New buildServerTLSConfigWithMTLS that wraps buildServerTLSConfig
+ sets ClientCAs + ClientAuth=VerifyClientCertIfGiven when a
non-nil pool is passed. nil pool = identical TLS shape to the
pre-Phase-6.5 builder (no behavior change for deploys without
mTLS profiles).
* Critical: VerifyClientCertIfGiven (NOT RequireAndVerifyClientCert)
so a client that doesn't present a cert can still hit the standard
/scep route. The per-profile gate at the handler layer enforces
'cert required' on /scep-mtls/<pathID>.
internal/api/handler/scep.go
* SCEPHandler gains mtlsTrustPool *x509.CertPool field +
SetMTLSTrustPool method. Per-profile pool injected by
cmd/server/main.go after preflight.
* HandleSCEPMTLS wrapper: gates on r.TLS.PeerCertificates non-empty
+ per-profile cert.Verify against THIS profile's pool. Returns
HTTP 401 for missing/untrusted cert (mTLS failure is auth, not
authorization). Returns HTTP 500 if mtlsTrustPool is nil (deploy
bug — the route shouldn't have been registered). On success
delegates to HandleSCEP — defense in depth: mTLS is additive,
NOT replacement; the standard SCEP code path including the
challenge-password gate still executes.
* Per-profile re-verification via cert.Verify(...) is critical:
the TLS layer verified against the UNION pool, so a cert that
chains to profile A's bundle would pass TLS even when targeting
profile B. The handler-layer gate prevents cross-profile
bleed-through.
internal/api/router/router.go
* AuthExemptDispatchPrefixes gains '/scep-mtls' (auth boundary is
client cert + challenge password, NOT Bearer token).
* RegisterSCEPMTLSHandlers parallel to RegisterSCEPHandlers:
empty PathID maps to /scep-mtls root; non-empty maps to
/scep-mtls/<pathID>. Each handler in the map MUST have had
SetMTLSTrustPool called.
internal/api/router/openapi_parity_test.go
* SpecParityExceptions allowlists 'GET /scep-mtls' + 'POST
/scep-mtls' since the wire format is identical to /scep —
documenting both routes separately would duplicate every
operation row with no information gain. Documented alternative
in docs/legacy-est-scep.md.
internal/api/handler/scep_mtls_test.go (new, ~210 LoC)
* 6 tests + 2 helpers covering the auth contract:
1. RejectsMissingClientCert — request with r.TLS=nil → 401
2. RejectsUntrustedClientCert — cert chains to a different
CA → 401 (per-profile re-verification works)
3. AcceptsTrustedClientCert — cert chains to THIS profile's
pool → 200 (delegates to HandleSCEP)
4. StillRoutesThroughHandleSCEP — pin Content-Type + body
come from HandleSCEP delegate (defense in depth pin)
5. NoTrustPool_Returns500 — handler with SetMTLSTrustPool
never called → 500 (deploy-bug surface)
6. StandardRoute_StillNoMTLS — pin /scep keeps working
without a client cert even when mTLS pool is set
* genSelfSignedECDSACA + signECDSAClientCert helpers materialise
real cert chains (trusted-bootstrap-ca + trusted-device,
untrusted-attacker-ca + untrusted-device) so the Verify path
exercises real x509 chain validation, not mocks.
docs/features.md
* SCEP env-vars table extended with the two new MTLS env vars
(CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED,
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH).
Closes the G-3 'env var defined in Go but never documented' gate.
docs/legacy-est-scep.md
* New 'mTLS sibling route (Phase 6.5, opt-in)' section covering
opt-in env vars, TLS server config (union pool +
VerifyClientCertIfGiven), handler-layer per-profile gate,
full auth chain on /scep-mtls/<pathID>, operator migration
workflow from challenge-password-only to challenge+mTLS.
cowork/CLAUDE.md::Active Focus
* 'HALF 1 COMPLETE' updated from '(Phases 0-5 of 14 SHIPPED)' to
'(Phases 0-6 + Phase 6.5 of 14 SHIPPED)'.
Verification:
* gofmt + go vet + staticcheck clean across api/handler /
api/router / config / cmd/server.
* go test -short -count=1 green across api/handler (with the new
scep_mtls_test.go) / api/router / service / config / pkcs7 /
cmd/server / connector/issuer/local.
* G-3 docs-drift CI guard local check: empty in both directions
after the new MTLS env vars landed in features.md.
* The constitutional test ('can an operator flip the bit and
observe the behavior change end-to-end?') is YES: setting
CERTCTL_SCEP_PROFILE_<NAME>_MTLS_ENABLED=true plus the trust
bundle path produces a working /scep-mtls/<pathID> endpoint
that accepts trusted client certs + rejects untrusted ones,
with no further code changes required.
Phase 6.5 of 14 in SCEP RFC 8894 + Intune master bundle.
Half 1 (Phases 0-6 + 6.5) is now FEATURE-COMPLETE for the
ChromeOS / general-MDM use case. Half 2 (Phases 7-12) adds the
Microsoft Intune dynamic-challenge layer.
191 lines
7.8 KiB
Go
191 lines
7.8 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"sync"
|
|
"syscall"
|
|
)
|
|
|
|
// certHolder stores the server's TLS certificate under a mutex so it can be
|
|
// swapped atomically by a SIGHUP handler without restarting the server. A
|
|
// *tls.Config that wires GetCertificate → (*certHolder).GetCertificate reads
|
|
// through the holder on every ClientHello, so a successful reload takes
|
|
// effect on the next new connection immediately and without dropping
|
|
// in-flight requests.
|
|
//
|
|
// Concurrency: GetCertificate is invoked from crypto/tls handshake goroutines
|
|
// on every new inbound connection; Reload is invoked from the SIGHUP watcher
|
|
// goroutine. sync.Mutex is sufficient — TLS handshakes are not an inner-loop
|
|
// hot path and the critical section is a single pointer read.
|
|
type certHolder struct {
|
|
mu sync.Mutex
|
|
cert *tls.Certificate
|
|
certPath string
|
|
keyPath string
|
|
}
|
|
|
|
// newCertHolder loads the initial cert+key pair from disk and returns a
|
|
// holder ready to serve handshakes. Returns a non-nil error if either file
|
|
// is missing, unreadable, or the pair does not round-trip through
|
|
// tls.LoadX509KeyPair (for example the key does not sign the cert). The
|
|
// caller is expected to treat a non-nil error as a fail-loud startup gate
|
|
// and os.Exit(1) — the HTTPS-everywhere milestone (§3 locked decisions)
|
|
// prohibits plaintext HTTP fallback.
|
|
func newCertHolder(certPath, keyPath string) (*certHolder, error) {
|
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load TLS cert/key (cert=%q key=%q): %w", certPath, keyPath, err)
|
|
}
|
|
return &certHolder{
|
|
cert: &cert,
|
|
certPath: certPath,
|
|
keyPath: keyPath,
|
|
}, nil
|
|
}
|
|
|
|
// GetCertificate is the tls.Config.GetCertificate hook. Returns the current
|
|
// cert under the holder's mutex. ClientHelloInfo is ignored — the control
|
|
// plane does not multiplex by SNI.
|
|
func (h *certHolder) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
return h.cert, nil
|
|
}
|
|
|
|
// Reload re-reads the cert+key pair from disk and swaps the holder
|
|
// atomically on success. On failure the holder retains its previous cert
|
|
// and the error is propagated to the caller — the SIGHUP watcher logs and
|
|
// keeps serving the previous cert rather than crashing on a bad reload.
|
|
// This is deliberately "fail-safe on reload, fail-loud on startup": an
|
|
// operator rotating certs wants a recoverable error, not a restart loop.
|
|
func (h *certHolder) Reload() error {
|
|
cert, err := tls.LoadX509KeyPair(h.certPath, h.keyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("reload TLS cert/key (cert=%q key=%q): %w", h.certPath, h.keyPath, err)
|
|
}
|
|
h.mu.Lock()
|
|
h.cert = &cert
|
|
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.
|
|
//
|
|
// Defensive design note: this deliberately does NOT panic on Reload error
|
|
// even though HTTPS is mission-critical. A rotation that writes half-files
|
|
// (operator overwrites cert.pem then key.pem as two separate copies) would
|
|
// otherwise crash the server mid-rotation. Logging + retaining the old
|
|
// cert gives the operator a bounded window to fix and re-SIGHUP.
|
|
func (h *certHolder) watchSIGHUP(logger *slog.Logger) (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 {
|
|
logger.Error("TLS cert reload failed; continuing with previous cert",
|
|
"error", err,
|
|
"cert_path", h.certPath,
|
|
"key_path", h.keyPath)
|
|
continue
|
|
}
|
|
logger.Info("TLS cert reloaded via SIGHUP",
|
|
"cert_path", h.certPath,
|
|
"key_path", h.keyPath)
|
|
case <-done:
|
|
signal.Stop(ch)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return func() { close(done) }
|
|
}
|
|
|
|
// buildServerTLSConfig returns the TLS 1.3-only *tls.Config for the HTTPS
|
|
// server. Pinned per HTTPS-everywhere milestone §2.1 + §3 locked decisions:
|
|
//
|
|
// - MinVersion: TLS 1.3 (no TLS 1.2 escape hatch). Go 1.25's crypto/tls
|
|
// automatically rejects older versions.
|
|
// - CurvePreferences: explicit [X25519, P-256]. Explicit ordering keeps
|
|
// the handshake deterministic and documents the accepted curves.
|
|
// - No CipherSuites field: TLS 1.3 cipher suites are not negotiable in
|
|
// the handshake (all three mandatory suites — AES-128-GCM-SHA256,
|
|
// AES-256-GCM-SHA384, CHACHA20-POLY1305-SHA256 — are always offered).
|
|
// Go's crypto/tls ignores CipherSuites for TLS 1.3.
|
|
// - GetCertificate: reads through the holder so SIGHUP rotations take
|
|
// effect on the next new connection without a restart. Setting
|
|
// tls.Config.Certificates directly would pin the first-loaded cert
|
|
// and defeat SIGHUP reload.
|
|
func buildServerTLSConfig(holder *certHolder) *tls.Config {
|
|
return &tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
|
|
GetCertificate: holder.GetCertificate,
|
|
}
|
|
}
|
|
|
|
// buildServerTLSConfigWithMTLS extends buildServerTLSConfig with a client-cert
|
|
// trust pool for the SCEP RFC 8894 + Intune master bundle Phase 6.5 mTLS
|
|
// sibling route. SCEP profiles that opt into mTLS each contribute their
|
|
// trust bundle to the union pool here; the same TLS listener serves both
|
|
// /scep[/<pathID>] (no client cert) and /scep-mtls/<pathID> (cert required
|
|
// at the handler layer).
|
|
//
|
|
// ClientAuth: VerifyClientCertIfGiven — request a cert during handshake; if
|
|
// the client presents one, verify it against the union pool; if absent, the
|
|
// request still reaches the handler and the per-route handler decides
|
|
// whether to accept. Critical that we do NOT use RequireAndVerifyClientCert
|
|
// here — that would break the standard /scep route (which is challenge-
|
|
// password-only, no client cert expected).
|
|
//
|
|
// Pass clientCAs == nil to disable mTLS (no profile opted in). The function
|
|
// then returns the same shape as buildServerTLSConfig.
|
|
func buildServerTLSConfigWithMTLS(holder *certHolder, clientCAs *x509.CertPool) *tls.Config {
|
|
cfg := buildServerTLSConfig(holder)
|
|
if clientCAs != nil {
|
|
cfg.ClientCAs = clientCAs
|
|
cfg.ClientAuth = tls.VerifyClientCertIfGiven
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// preflightServerTLS is the fail-loud startup gate for HTTPS. Returns a
|
|
// non-nil error when the TLS configuration is missing or the cert+key pair
|
|
// cannot be parsed, so the caller refuses to start the control plane
|
|
// (HTTPS-everywhere §3 locked decisions: no plaintext HTTP fallback).
|
|
//
|
|
// Duplicates the emptiness + stat + parse checks in config.Validate() for
|
|
// defense in depth, mirroring the pattern established by
|
|
// preflightSCEPChallengePassword (which itself duplicates
|
|
// config.Validate()'s SCEP check for CWE-306). Extracted into a separate
|
|
// function so the gate is unit-testable without booting the full server.
|
|
func preflightServerTLS(certPath, keyPath string) error {
|
|
if certPath == "" {
|
|
return fmt.Errorf("CERTCTL_SERVER_TLS_CERT_PATH is empty: HTTPS-only control plane refuses to start (see docs/tls.md)")
|
|
}
|
|
if keyPath == "" {
|
|
return fmt.Errorf("CERTCTL_SERVER_TLS_KEY_PATH is empty: HTTPS-only control plane refuses to start (see docs/tls.md)")
|
|
}
|
|
if _, err := os.Stat(certPath); err != nil {
|
|
return fmt.Errorf("TLS cert file %q unreadable: %w (see docs/tls.md)", certPath, err)
|
|
}
|
|
if _, err := os.Stat(keyPath); err != nil {
|
|
return fmt.Errorf("TLS key file %q unreadable: %w (see docs/tls.md)", keyPath, err)
|
|
}
|
|
if _, err := tls.LoadX509KeyPair(certPath, keyPath); err != nil {
|
|
return fmt.Errorf("TLS cert/key pair invalid (cert=%q key=%q): %w (see docs/tls.md)", certPath, keyPath, err)
|
|
}
|
|
return nil
|
|
}
|