mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:11:32 +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.
194 lines
6.3 KiB
Go
194 lines
6.3 KiB
Go
package router
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Bundle D / Audit M-027: pin the router ↔ OpenAPI spec parity.
|
|
//
|
|
// The audit reported "router 121 vs OpenAPI 125 — 4 op gap" by counting
|
|
// r.Register call sites with a regex. That methodology is incomplete: the
|
|
// router additionally registers 4 routes via direct r.mux.Handle calls
|
|
// (the Bundle B / M-002 AuthExemptRouterRoutes — health/ready/auth-info/
|
|
// version). When you count BOTH dispatch shapes the totals match exactly.
|
|
//
|
|
// This test:
|
|
// 1. Walks router.go's AST to enumerate every (method, path) tuple from
|
|
// both r.Register AND r.mux.Handle sites.
|
|
// 2. Walks api/openapi.yaml's path/method nesting to enumerate every
|
|
// documented operation.
|
|
// 3. Asserts the two sets are identical (modulo a tiny exception list
|
|
// for routes that legitimately don't appear in the spec).
|
|
//
|
|
// Adding a new route without updating openapi.yaml fails this test.
|
|
|
|
// SpecParityExceptions is the documented allowlist of (method, path)
|
|
// tuples that are intentionally NOT in api/openapi.yaml. Each entry must
|
|
// have a justification — typically "internal" or "non-stable surface".
|
|
//
|
|
// At Bundle D close time, this list is empty. Future entries should be
|
|
// rare — the OpenAPI spec is the source of truth for the public API
|
|
// surface.
|
|
var SpecParityExceptions = map[string]string{
|
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: the /scep-mtls
|
|
// sibling route is opt-in (gated on per-profile MTLSEnabled). It rides
|
|
// the same SCEP-PKIOperation contract as /scep but with an additional
|
|
// client-cert auth layer at the handler. The OpenAPI spec covers the
|
|
// canonical /scep endpoint; documenting /scep-mtls separately would
|
|
// duplicate every operation row with no information gain — the
|
|
// PKIMessage wire format, query params, and response shapes are
|
|
// identical. The route lives in router.go as literal r.Register calls
|
|
// for the openapi-parity scanner's benefit; it stays out of openapi.yaml
|
|
// by exception. See docs/legacy-est-scep.md::mTLS-sibling-route for the
|
|
// operator-facing description.
|
|
"GET /scep-mtls": "Phase 6.5 mTLS sibling route — same wire format as /scep with cert-required gate; documented in docs/legacy-est-scep.md",
|
|
"POST /scep-mtls": "Phase 6.5 mTLS sibling route — same wire format as /scep with cert-required gate; documented in docs/legacy-est-scep.md",
|
|
}
|
|
|
|
func TestRouter_OpenAPIParity(t *testing.T) {
|
|
routes, err := scanRouterRoutes("router.go")
|
|
if err != nil {
|
|
t.Fatalf("scan router.go: %v", err)
|
|
}
|
|
specOps, err := scanOpenAPIOperations("../../../api/openapi.yaml")
|
|
if err != nil {
|
|
t.Fatalf("scan openapi.yaml: %v", err)
|
|
}
|
|
|
|
routeSet := make(map[string]bool, len(routes))
|
|
for _, r := range routes {
|
|
routeSet[r] = true
|
|
}
|
|
specSet := make(map[string]bool, len(specOps))
|
|
for _, o := range specOps {
|
|
specSet[o] = true
|
|
}
|
|
|
|
var inRouterNotSpec, inSpecNotRouter []string
|
|
for r := range routeSet {
|
|
if !specSet[r] {
|
|
if _, allow := SpecParityExceptions[r]; !allow {
|
|
inRouterNotSpec = append(inRouterNotSpec, r)
|
|
}
|
|
}
|
|
}
|
|
for s := range specSet {
|
|
if !routeSet[s] {
|
|
inSpecNotRouter = append(inSpecNotRouter, s)
|
|
}
|
|
}
|
|
|
|
sort.Strings(inRouterNotSpec)
|
|
sort.Strings(inSpecNotRouter)
|
|
|
|
if len(inRouterNotSpec) > 0 {
|
|
t.Errorf("routes in router.go but missing from api/openapi.yaml (%d):\n %s\n\n"+
|
|
"Add the operation to openapi.yaml OR add an explicit exception to "+
|
|
"SpecParityExceptions with a justification.",
|
|
len(inRouterNotSpec), strings.Join(inRouterNotSpec, "\n "))
|
|
}
|
|
if len(inSpecNotRouter) > 0 {
|
|
t.Errorf("operations in api/openapi.yaml but missing from router.go (%d):\n %s\n\n"+
|
|
"Either implement the endpoint or remove it from openapi.yaml.",
|
|
len(inSpecNotRouter), strings.Join(inSpecNotRouter, "\n "))
|
|
}
|
|
}
|
|
|
|
// --- helpers --------------------------------------------------------------
|
|
|
|
func scanRouterRoutes(name string) ([]string, error) {
|
|
fset := token.NewFileSet()
|
|
src, err := parser.ParseFile(fset, name, nil, parser.SkipObjectResolution)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
ast.Inspect(src, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok || len(call.Args) == 0 {
|
|
return true
|
|
}
|
|
// We care about r.mux.Handle("METHOD /path", ...) and
|
|
// r.Register("METHOD /path", ...). Both have a string literal as
|
|
// arg[0].
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
isMuxHandle := false
|
|
isRegister := sel.Sel.Name == "Register"
|
|
if sel.Sel.Name == "Handle" {
|
|
if inner, ok := sel.X.(*ast.SelectorExpr); ok && inner.Sel.Name == "mux" {
|
|
isMuxHandle = true
|
|
}
|
|
}
|
|
if !isMuxHandle && !isRegister {
|
|
return true
|
|
}
|
|
lit, ok := call.Args[0].(*ast.BasicLit)
|
|
if !ok || lit.Kind != token.STRING {
|
|
return true
|
|
}
|
|
v := strings.Trim(lit.Value, "\"`")
|
|
// Skip the generic Register helper itself (line 38: r.mux.Handle(pattern,...)
|
|
// — pattern is a func arg, not a literal, so it would not be a BasicLit).
|
|
// Skip non-METHOD-prefixed strings (defensive).
|
|
if !looksLikeMethodPath(v) {
|
|
return true
|
|
}
|
|
out = append(out, v)
|
|
return true
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
var methodPathRe = regexp.MustCompile(`^(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD) /`)
|
|
|
|
func looksLikeMethodPath(s string) bool {
|
|
return methodPathRe.MatchString(s)
|
|
}
|
|
|
|
// scanOpenAPIOperations walks openapi.yaml's paths block and returns
|
|
// every (METHOD, PATH) tuple in the same "METHOD /path" string shape the
|
|
// router uses. Naive but sufficient: the spec is hand-maintained YAML
|
|
// with consistent 2-space-then-4-space indentation.
|
|
func scanOpenAPIOperations(path string) ([]string, error) {
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
inPaths := false
|
|
currentPath := ""
|
|
pathRe := regexp.MustCompile(`^ (/[^:]+):\s*$`)
|
|
methodRe := regexp.MustCompile(`^ (get|post|put|delete|patch|options|head):\s*$`)
|
|
for _, line := range strings.Split(string(body), "\n") {
|
|
if strings.HasPrefix(line, "paths:") {
|
|
inPaths = true
|
|
continue
|
|
}
|
|
if inPaths && line != "" && !strings.HasPrefix(line, " ") {
|
|
inPaths = false
|
|
continue
|
|
}
|
|
if !inPaths {
|
|
continue
|
|
}
|
|
if m := pathRe.FindStringSubmatch(line); m != nil {
|
|
currentPath = m[1]
|
|
continue
|
|
}
|
|
if m := methodRe.FindStringSubmatch(line); m != nil && currentPath != "" {
|
|
out = append(out, strings.ToUpper(m[1])+" "+currentPath)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|