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.
689 lines
28 KiB
Go
689 lines
28 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/shankar0123/certctl/internal/api/middleware"
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
|
)
|
|
|
|
// SCEPService defines the service interface for SCEP enrollment operations.
|
|
// SCEP (RFC 8894) is a protocol for certificate enrollment used by MDM platforms
|
|
// and network devices.
|
|
type SCEPService interface {
|
|
// GetCACaps returns the SCEP server capabilities as a newline-separated string.
|
|
GetCACaps(ctx context.Context) string
|
|
|
|
// GetCACert returns the PEM-encoded CA certificate chain.
|
|
GetCACert(ctx context.Context) (string, error)
|
|
|
|
// PKCSReq processes a PKCS#10 CSR and returns a signed certificate.
|
|
// Used by the MVP raw-CSR fall-through path; preserved unchanged for
|
|
// backward compat with lightweight SCEP clients.
|
|
PKCSReq(ctx context.Context, csrPEM string, challengePassword string, transactionID string) (*domain.SCEPEnrollResult, error)
|
|
|
|
// PKCSReqWithEnvelope processes a SCEP PKCSReq from the RFC 8894 path
|
|
// (the handler successfully parsed an EnvelopedData + signerInfo POPO).
|
|
// Returns *SCEPResponseEnvelope (not error + *SCEPEnrollResult) because
|
|
// RFC 8894 §3.3 mandates a CertRep PKIMessage on every response, even
|
|
// failures. Returns nil to signal 'invalid challenge password' (caller
|
|
// translates to HTTP 403, matching the MVP path's wire shape).
|
|
PKCSReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
|
|
|
// RenewalReqWithEnvelope processes a SCEP RenewalReq (RFC 8894 §3.3.1.2)
|
|
// from the RFC 8894 path. Same contract as PKCSReqWithEnvelope but the
|
|
// service additionally verifies that envelope.SignerCert chains to the
|
|
// issuer's CA — RenewalReq requires a previously-issued cert as POPO.
|
|
RenewalReqWithEnvelope(ctx context.Context, csrPEM string, challengePassword string, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
|
|
|
// GetCertInitialWithEnvelope handles SCEP polling requests (RFC 8894
|
|
// §3.3.3). The v1 implementation always returns FAILURE+badCertID
|
|
// because deferred-issuance isn't supported (every PKCSReq either
|
|
// succeeds or fails synchronously); wiring is in place for a future
|
|
// 'queue for manual approval' workflow.
|
|
GetCertInitialWithEnvelope(ctx context.Context, envelope *domain.SCEPRequestEnvelope) *domain.SCEPResponseEnvelope
|
|
}
|
|
|
|
// SCEPHandler handles HTTP requests for the SCEP protocol (RFC 8894).
|
|
//
|
|
// SCEP uses a single endpoint with operation-based dispatch via query parameters.
|
|
// All operations use GET or POST to the same path.
|
|
//
|
|
// Supported operations:
|
|
// - GET ?operation=GetCACaps — server capabilities
|
|
// - GET ?operation=GetCACert — CA certificate distribution
|
|
// - POST ?operation=PKIOperation — certificate enrollment (PKCSReq)
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 2.3: SCEPHandler now optionally
|
|
// carries an RA cert + key pair. When set, the handler tries the new RFC 8894
|
|
// PKIMessage path FIRST (parse SignedData → verify POPO → decrypt EnvelopedData).
|
|
// On any parse failure it falls through to the legacy MVP raw-CSR path (preserves
|
|
// backward compat with lightweight SCEP clients). When RA pair is unset, the
|
|
// handler runs MVP-only (the v2.0.x behavior).
|
|
type SCEPHandler struct {
|
|
svc SCEPService
|
|
raCert *x509.Certificate // RFC 8894 path: RA cert clients encrypt CSR to
|
|
raKey crypto.PrivateKey // RFC 8894 path: RA key for EnvelopedData decrypt + CertRep signing
|
|
|
|
// SCEP RFC 8894 + Intune master bundle Phase 6.5: per-profile mTLS
|
|
// trust bundle. When set, HandleSCEPMTLS verifies the inbound client
|
|
// cert chain against this pool. Nil when the profile has MTLSEnabled=false
|
|
// — HandleSCEPMTLS rejects unconditionally in that case (the route
|
|
// shouldn't even be registered, but defense in depth).
|
|
mtlsTrustPool *x509.CertPool
|
|
}
|
|
|
|
// NewSCEPHandler creates a new SCEPHandler with the legacy MVP-only behavior.
|
|
// SetRAPair below upgrades the handler to the RFC 8894 path; that's the route
|
|
// cmd/server/main.go takes when the operator supplies CERTCTL_SCEP_RA_*.
|
|
func NewSCEPHandler(svc SCEPService) SCEPHandler {
|
|
return SCEPHandler{svc: svc}
|
|
}
|
|
|
|
// SetRAPair injects the RA cert + key the RFC 8894 path needs. Called by
|
|
// cmd/server/main.go after the per-profile preflight gate validates the pair.
|
|
// Without this call the handler runs MVP-only (the legacy v2.0.x behavior).
|
|
func (h *SCEPHandler) SetRAPair(raCert *x509.Certificate, raKey crypto.PrivateKey) {
|
|
h.raCert = raCert
|
|
h.raKey = raKey
|
|
}
|
|
|
|
// SetMTLSTrustPool injects the per-profile client-cert trust pool the
|
|
// `/scep-mtls/<PathID>` sibling route uses to verify inbound device
|
|
// bootstrap certs. SCEP RFC 8894 + Intune master bundle Phase 6.5.
|
|
//
|
|
// The TLS layer (cmd/server/main.go::buildServerTLSConfig) uses
|
|
// VerifyClientCertIfGiven against the UNION of every enabled mTLS
|
|
// profile's bundle, so the same TLS listener serves both /scep
|
|
// (challenge-password-only) and /scep-mtls/<PathID> (cert + challenge).
|
|
// The per-profile gate at the handler layer enforces 'cert must chain to
|
|
// THIS profile's bundle' so a cert that chains to profile A's bundle
|
|
// cannot enroll against profile B even though it passed the TLS layer.
|
|
func (h *SCEPHandler) SetMTLSTrustPool(pool *x509.CertPool) {
|
|
h.mtlsTrustPool = pool
|
|
}
|
|
|
|
// HandleSCEPMTLS is the entry point for the `/scep-mtls/<PathID>` sibling
|
|
// route. SCEP RFC 8894 + Intune master bundle Phase 6.5.
|
|
//
|
|
// Gates on the inbound client cert chain — the request must:
|
|
//
|
|
// 1. Carry a TLS connection (r.TLS != nil) — defense in depth even
|
|
// though the HTTPS-only listener guarantees this.
|
|
// 2. Have presented a peer cert (len(r.TLS.PeerCertificates) > 0) — the
|
|
// listener uses VerifyClientCertIfGiven, so a missing cert is a
|
|
// legitimate failure here, not a TLS error.
|
|
// 3. The peer cert chain must verify against THIS profile's trust pool
|
|
// (h.mtlsTrustPool). The TLS layer verified against the union pool
|
|
// of all mTLS profiles, but a cert that chains to profile A cannot
|
|
// enroll against profile B — verify per-profile here.
|
|
//
|
|
// Failures return HTTP 401 (Unauthorized — mTLS failure is authentication,
|
|
// not authorization). On success the call delegates to HandleSCEP — the
|
|
// challenge-password gate still fires (defense in depth: mTLS is additive,
|
|
// not replacement).
|
|
func (h SCEPHandler) HandleSCEPMTLS(w http.ResponseWriter, r *http.Request) {
|
|
if h.mtlsTrustPool == nil {
|
|
// Profile is misconfigured — handler registered for /scep-mtls but
|
|
// SetMTLSTrustPool was never called. The startup preflight should
|
|
// have caught this; surfacing as 500 makes the deploy bug loud.
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, "mTLS handler missing trust pool", middleware.GetRequestID(r.Context()))
|
|
return
|
|
}
|
|
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
|
|
// Client didn't present a cert. With VerifyClientCertIfGiven the
|
|
// TLS handshake completes anyway — the per-profile gate enforces
|
|
// 'cert required' at the application layer.
|
|
ErrorWithRequestID(w, http.StatusUnauthorized, "Client certificate required for /scep-mtls", middleware.GetRequestID(r.Context()))
|
|
return
|
|
}
|
|
leaf := r.TLS.PeerCertificates[0]
|
|
intermediates := x509.NewCertPool()
|
|
for _, c := range r.TLS.PeerCertificates[1:] {
|
|
intermediates.AddCert(c)
|
|
}
|
|
if _, err := leaf.Verify(x509.VerifyOptions{
|
|
Roots: h.mtlsTrustPool,
|
|
Intermediates: intermediates,
|
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageAny},
|
|
}); err != nil {
|
|
ErrorWithRequestID(w, http.StatusUnauthorized, "Client certificate not trusted by this profile", middleware.GetRequestID(r.Context()))
|
|
return
|
|
}
|
|
// Defense in depth — mTLS is ADDITIVE. The request still flows through
|
|
// HandleSCEP which enforces the challenge-password gate at the service
|
|
// layer. A stolen device cert without the matching challenge password
|
|
// still gets rejected (and vice versa).
|
|
h.HandleSCEP(w, r)
|
|
}
|
|
|
|
// HandleSCEP is the single entry point for all SCEP operations.
|
|
// It dispatches based on the "operation" query parameter.
|
|
func (h SCEPHandler) HandleSCEP(w http.ResponseWriter, r *http.Request) {
|
|
operation := r.URL.Query().Get("operation")
|
|
|
|
switch operation {
|
|
case "GetCACaps":
|
|
h.getCACaps(w, r)
|
|
case "GetCACert":
|
|
h.getCACert(w, r)
|
|
case "PKIOperation":
|
|
h.pkiOperation(w, r)
|
|
default:
|
|
http.Error(w, fmt.Sprintf("Unknown SCEP operation: %s", operation), http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
// getCACaps handles GET ?operation=GetCACaps
|
|
// Returns the SCEP server capabilities as plaintext, one per line.
|
|
func (h SCEPHandler) getCACaps(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
caps := h.svc.GetCACaps(r.Context())
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(caps))
|
|
}
|
|
|
|
// getCACert handles GET ?operation=GetCACert
|
|
// Returns the CA certificate(s). Single cert as DER, chain as PKCS#7.
|
|
func (h SCEPHandler) getCACert(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
caCertPEM, err := h.svc.GetCACert(r.Context())
|
|
if err != nil {
|
|
requestID := middleware.GetRequestID(r.Context())
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CA certificate: %v", err), requestID)
|
|
return
|
|
}
|
|
|
|
// Parse PEM to DER chain
|
|
derCerts, err := pkcs7.PEMToDERChain(caCertPEM)
|
|
if err != nil {
|
|
requestID := middleware.GetRequestID(r.Context())
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to parse CA certificates", requestID)
|
|
return
|
|
}
|
|
|
|
if len(derCerts) == 1 {
|
|
// Single CA cert — return as raw DER
|
|
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(derCerts[0])
|
|
return
|
|
}
|
|
|
|
// Multiple certs (CA + RA or chain) — return as PKCS#7
|
|
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
|
|
if err != nil {
|
|
requestID := middleware.GetRequestID(r.Context())
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to build PKCS#7 response", requestID)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-x509-ca-ra-cert")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(pkcs7Data)
|
|
}
|
|
|
|
// pkiOperation handles POST ?operation=PKIOperation
|
|
// Processes a SCEP enrollment request containing a PKCS#7-wrapped CSR.
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 2.3: this handler tries the
|
|
// new RFC 8894 PKIMessage path FIRST (parse outer SignedData → verify
|
|
// signerInfo POPO → extract authenticatedAttributes → decrypt EnvelopedData
|
|
// to recover the inner CSR). On any parse failure it falls through to the
|
|
// legacy MVP raw-CSR path (extractCSRFromPKCS7). The MVP path stays
|
|
// unchanged for backward compat with lightweight SCEP clients.
|
|
//
|
|
// Path selection rules:
|
|
// - h.raCert / h.raKey unset → MVP-only (legacy v2.0.x behavior, never tries RFC 8894)
|
|
// - RA pair set + RFC 8894 parse succeeds → RFC 8894 path (CertRep PKIMessage response)
|
|
// - RA pair set + RFC 8894 parse fails → MVP fall-through (degenerate certs-only response)
|
|
//
|
|
// The Phase 3 commit will replace the MVP-fall-through writeSCEPResponse
|
|
// with writeCertRepPKIMessage for the RFC 8894 path; the MVP path keeps
|
|
// using writeSCEPResponse so lightweight clients see no behavior change.
|
|
func (h SCEPHandler) pkiOperation(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
requestID := middleware.GetRequestID(r.Context())
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
|
if err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, "Failed to read request body", requestID)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
if len(body) == 0 {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, "Empty request body", requestID)
|
|
return
|
|
}
|
|
|
|
// Try the RFC 8894 path first when an RA pair is configured. On any
|
|
// parse failure we fall through to the MVP path silently — that's the
|
|
// backward-compat contract for lightweight clients.
|
|
if h.raCert != nil && h.raKey != nil {
|
|
if envelope, csrPEM, challengePassword, ok := h.tryParseRFC8894(body); ok {
|
|
// SCEP RFC 8894 + Intune master bundle Phase 4.1: dispatch on
|
|
// the parsed messageType. PKCSReq + RenewalReq exercise the
|
|
// full enrollment pipeline (different audit actions + chain
|
|
// validation for renewal); GetCertInitial is the polling
|
|
// shape (v1 stub returns badCertID since deferred-issuance
|
|
// isn't supported); unknown messageType returns CertRep with
|
|
// FAILURE+badRequest per RFC 8894 §3.3.2.2.
|
|
var resp *domain.SCEPResponseEnvelope
|
|
switch envelope.MessageType {
|
|
case domain.SCEPMessageTypePKCSReq:
|
|
resp = h.svc.PKCSReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
|
case domain.SCEPMessageTypeRenewalReq:
|
|
resp = h.svc.RenewalReqWithEnvelope(r.Context(), csrPEM, challengePassword, envelope)
|
|
case domain.SCEPMessageTypeGetCertInitial:
|
|
resp = h.svc.GetCertInitialWithEnvelope(r.Context(), envelope)
|
|
default:
|
|
// Unknown messageType — emit a CertRep+FAILURE so the
|
|
// client sees a structured response rather than a vague
|
|
// 400. RFC 8894 §3.2.1.4.1 enumerates the valid types;
|
|
// anything else is a malformed client.
|
|
resp = &domain.SCEPResponseEnvelope{
|
|
Status: domain.SCEPStatusFailure,
|
|
FailInfo: domain.SCEPFailBadRequest,
|
|
TransactionID: envelope.TransactionID,
|
|
RecipientNonce: envelope.SenderNonce,
|
|
}
|
|
}
|
|
if resp == nil {
|
|
// nil signals 'invalid challenge password' from the
|
|
// service layer (only PKCSReq + RenewalReq paths can
|
|
// return nil — GetCertInitial always returns a
|
|
// CertRep). RFC 8894 §3.3.1 is silent on whether to
|
|
// return a CertRep or an HTTP error for the wrong-
|
|
// password case; we mirror the MVP path's HTTP 403
|
|
// wire shape so the client sees a clear auth failure
|
|
// rather than trying to interpret a structurally-valid
|
|
// CertRep+failInfo (which conflates 'wrong secret'
|
|
// with 'wrong CSR shape').
|
|
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
|
|
return
|
|
}
|
|
// SCEP RFC 8894 Phase 3.2: emit CertRep PKIMessage for both
|
|
// success AND failure paths (RFC 8894 §3.3 mandates a
|
|
// PKIMessage response on every PKIOperation request, including
|
|
// failures). The MVP path keeps using writeSCEPResponse —
|
|
// that's the legacy certs-only response shape lightweight
|
|
// clients understand.
|
|
h.writeCertRepPKIMessage(w, r, envelope, resp)
|
|
return
|
|
}
|
|
// RFC 8894 parse failed — fall through to the MVP path.
|
|
}
|
|
|
|
// MVP path: extract the PKCS#10 CSR from the PKCS#7 SignedData envelope
|
|
// using the legacy parser. This is what lightweight clients (raw-CSR-
|
|
// inside-SignedData, or even bare CSRs in some cases) hit.
|
|
csrDER, challengePassword, transactionID, err := extractCSRFromPKCS7(body)
|
|
if err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid SCEP message: %v", err), requestID)
|
|
return
|
|
}
|
|
|
|
// Validate the CSR
|
|
csr, err := x509.ParseCertificateRequest(csrDER)
|
|
if err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("Invalid CSR: %v", err), requestID)
|
|
return
|
|
}
|
|
if err := csr.CheckSignature(); err != nil {
|
|
ErrorWithRequestID(w, http.StatusBadRequest, fmt.Sprintf("CSR signature invalid: %v", err), requestID)
|
|
return
|
|
}
|
|
|
|
// Convert DER CSR to PEM for the service layer
|
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE REQUEST",
|
|
Bytes: csrDER,
|
|
}))
|
|
|
|
result, err := h.svc.PKCSReq(r.Context(), csrPEM, challengePassword, transactionID)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "challenge password") {
|
|
ErrorWithRequestID(w, http.StatusForbidden, "Invalid challenge password", requestID)
|
|
return
|
|
}
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Enrollment failed: %v", err), requestID)
|
|
return
|
|
}
|
|
|
|
// Build response: issued cert wrapped in PKCS#7 certs-only
|
|
h.writeSCEPResponse(w, result)
|
|
}
|
|
|
|
// tryParseRFC8894 attempts to parse the request body as an RFC 8894 SCEP
|
|
// PKIMessage:
|
|
// 1. Parse outer SignedData; pluck the device's transient signing cert.
|
|
// 2. Verify the signerInfo signature (POPO over auth-attrs).
|
|
// 3. Extract messageType / transactionID / senderNonce auth-attrs.
|
|
// 4. The encapContent is the inner pkcsPKIEnvelope (an EnvelopedData);
|
|
// decrypt it with h.raKey to recover the PKCS#10 CSR DER.
|
|
// 5. Parse the CSR + extract the challengePassword attribute (RFC 2985
|
|
// §5.4.1) so the service-layer's challenge-password gate can run.
|
|
// 6. PEM-encode the CSR for the service layer.
|
|
//
|
|
// Returns (envelope, csrPEM, challengePassword, true) on success;
|
|
// (nil, "", "", false) on any parse / verify / decrypt failure. The
|
|
// handler treats false as 'fall through to MVP path' so lightweight
|
|
// clients keep working.
|
|
func (h SCEPHandler) tryParseRFC8894(body []byte) (*domain.SCEPRequestEnvelope, string, string, bool) {
|
|
sd, err := pkcs7.ParseSignedData(body)
|
|
if err != nil {
|
|
return nil, "", "", false
|
|
}
|
|
if len(sd.SignerInfos) == 0 {
|
|
return nil, "", "", false
|
|
}
|
|
si := sd.SignerInfos[0]
|
|
if err := si.VerifySignature(); err != nil {
|
|
return nil, "", "", false
|
|
}
|
|
mt, err := si.GetMessageType()
|
|
if err != nil {
|
|
return nil, "", "", false
|
|
}
|
|
tid, err := si.GetTransactionID()
|
|
if err != nil {
|
|
return nil, "", "", false
|
|
}
|
|
nonce, err := si.GetSenderNonce()
|
|
if err != nil {
|
|
// senderNonce is optional in some clients; treat missing as empty.
|
|
nonce = nil
|
|
}
|
|
// EncapContent is the inner pkcsPKIEnvelope (EnvelopedData). Parse +
|
|
// decrypt with the RA key.
|
|
if len(sd.EncapContent) == 0 {
|
|
return nil, "", "", false
|
|
}
|
|
env, err := pkcs7.ParseEnvelopedData(sd.EncapContent)
|
|
if err != nil {
|
|
return nil, "", "", false
|
|
}
|
|
csrDER, err := env.Decrypt(h.raKey, h.raCert)
|
|
if err != nil {
|
|
return nil, "", "", false
|
|
}
|
|
// Verify the recovered bytes really are a CSR. If not, fall through.
|
|
csr, err := x509.ParseCertificateRequest(csrDER)
|
|
if err != nil {
|
|
return nil, "", "", false
|
|
}
|
|
// Extract the challengePassword attribute (RFC 2985 §5.4.1). Empty
|
|
// when missing; the service-layer gate then refuses with 'invalid
|
|
// challenge password' (correct behavior for clients that omit the
|
|
// auth attribute).
|
|
challengePassword := extractChallengePasswordFromCSR(csr)
|
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
|
|
envelope := &domain.SCEPRequestEnvelope{
|
|
MessageType: mt,
|
|
TransactionID: tid,
|
|
SenderNonce: nonce,
|
|
SignerCert: si.SignerCert.Raw,
|
|
}
|
|
return envelope, csrPEM, challengePassword, true
|
|
}
|
|
|
|
// extractChallengePasswordFromCSR walks the parsed CSR's attributes for
|
|
// the RFC 2985 §5.4.1 challengePassword (OID 1.2.840.113549.1.9.7).
|
|
// Returns empty string when missing.
|
|
//
|
|
// SA1019 carve-out: csr.Attributes is deprecated by Go's stdlib for the
|
|
// requestedExtensions attribute, but RFC 2985 challengePassword (OID
|
|
// 1.2.840.113549.1.9.7) is a SEPARATE CSR attribute that cannot be
|
|
// retrieved via csr.Extensions. There is no non-deprecated stdlib API
|
|
// for it; the same `lint:ignore SA1019` line precedent set by
|
|
// extractCSRFields applies here.
|
|
func extractChallengePasswordFromCSR(csr *x509.CertificateRequest) string {
|
|
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
|
|
//lint:ignore SA1019 RFC 2985 challengePassword has no non-deprecated stdlib API; see extractCSRFields docblock for the M-028 audit closure rationale.
|
|
for _, attr := range csr.Attributes {
|
|
if attr.Type.Equal(oidChallengePassword) {
|
|
if len(attr.Value) > 0 && len(attr.Value[0]) > 0 {
|
|
if pwd, ok := attr.Value[0][0].Value.(string); ok {
|
|
return pwd
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// writeCertRepPKIMessage builds and writes a SCEP CertRep PKIMessage as
|
|
// the response to a PKIOperation request that was successfully parsed
|
|
// via the RFC 8894 path.
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 3.2.
|
|
//
|
|
// Both success AND failure responses go through here — RFC 8894 §3.3
|
|
// mandates a PKIMessage response on every PKIOperation request, with
|
|
// pkiStatus + (on failure) failInfo signaling the outcome to the client.
|
|
//
|
|
// On failure to BUILD the response (a programmer / config bug — e.g. a
|
|
// device cert that's not RSA), we return HTTP 500 rather than try to
|
|
// construct a fallback PKIMessage that might re-trigger the same bug.
|
|
// Operators see a clear failure log + the request fails loud, which is
|
|
// preferable to silently emitting a half-built response.
|
|
func (h SCEPHandler) writeCertRepPKIMessage(w http.ResponseWriter, r *http.Request, req *domain.SCEPRequestEnvelope, resp *domain.SCEPResponseEnvelope) {
|
|
pkiMessageDER, err := pkcs7.BuildCertRepPKIMessage(req, resp, h.raCert, h.raKey)
|
|
if err != nil {
|
|
ErrorWithRequestID(w, http.StatusInternalServerError, fmt.Sprintf("Failed to build CertRep PKIMessage: %v", err), middleware.GetRequestID(r.Context()))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/x-pki-message")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(pkiMessageDER)
|
|
}
|
|
|
|
// silence unused-import warning if some narrow build excludes the path
|
|
// where crypto.PrivateKey is used (the RA key field above).
|
|
var _ crypto.PrivateKey = (*interface{})(nil)
|
|
|
|
// writeSCEPResponse writes a SCEP enrollment response as PKCS#7 certs-only (DER).
|
|
func (h SCEPHandler) writeSCEPResponse(w http.ResponseWriter, result *domain.SCEPEnrollResult) {
|
|
var derCerts [][]byte
|
|
|
|
certDER, err := pkcs7.PEMToDERChain(result.CertPEM)
|
|
if err != nil || len(certDER) == 0 {
|
|
http.Error(w, "Failed to encode certificate", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
derCerts = append(derCerts, certDER...)
|
|
|
|
if result.ChainPEM != "" {
|
|
chainDER, err := pkcs7.PEMToDERChain(result.ChainPEM)
|
|
if err == nil {
|
|
derCerts = append(derCerts, chainDER...)
|
|
}
|
|
}
|
|
|
|
pkcs7Data, err := pkcs7.BuildCertsOnlyPKCS7(derCerts)
|
|
if err != nil {
|
|
http.Error(w, "Failed to build PKCS#7 response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pki-message")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(pkcs7Data)
|
|
}
|
|
|
|
// extractCSRFromPKCS7 extracts a PKCS#10 CSR from a SCEP PKCS#7 SignedData envelope.
|
|
//
|
|
// SCEP clients wrap the CSR in a PKCS#7 SignedData structure. For the MVP, we parse
|
|
// the outer ASN.1 structure to find the encapsulated content (the CSR bytes), and
|
|
// extract the challenge password from the CSR attributes.
|
|
//
|
|
// Returns: csrDER, challengePassword, transactionID, error
|
|
func extractCSRFromPKCS7(data []byte) ([]byte, string, string, error) {
|
|
// Try to decode as PKCS#7 SignedData
|
|
csrDER, err := parseSignedDataForCSR(data)
|
|
if err != nil {
|
|
// Fallback: some clients send the CSR directly (not wrapped in PKCS#7)
|
|
// or send base64-encoded data
|
|
decoded, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
|
|
if decErr == nil {
|
|
// Try the decoded data as PKCS#7
|
|
csrDER2, err2 := parseSignedDataForCSR(decoded)
|
|
if err2 == nil {
|
|
return extractCSRFields(csrDER2)
|
|
}
|
|
// Maybe the decoded data IS the CSR directly
|
|
if _, parseErr := x509.ParseCertificateRequest(decoded); parseErr == nil {
|
|
return extractCSRFields(decoded)
|
|
}
|
|
}
|
|
// Maybe the raw data IS the CSR directly (no PKCS#7 wrapping)
|
|
if _, parseErr := x509.ParseCertificateRequest(data); parseErr == nil {
|
|
return extractCSRFields(data)
|
|
}
|
|
return nil, "", "", fmt.Errorf("failed to extract CSR from PKCS#7: %w", err)
|
|
}
|
|
return extractCSRFields(csrDER)
|
|
}
|
|
|
|
// extractCSRFields extracts the challenge password and transaction ID from CSR attributes.
|
|
func extractCSRFields(csrDER []byte) ([]byte, string, string, error) {
|
|
csr, err := x509.ParseCertificateRequest(csrDER)
|
|
if err != nil {
|
|
return nil, "", "", fmt.Errorf("invalid CSR: %w", err)
|
|
}
|
|
|
|
challengePassword := ""
|
|
transactionID := ""
|
|
|
|
// OID for challengePassword: 1.2.840.113549.1.9.7
|
|
oidChallengePassword := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 7}
|
|
|
|
// Extract challenge password from parsed CSR attributes.
|
|
// Attributes is []pkix.AttributeTypeAndValueSET where each has Type (OID)
|
|
// and Value ([][]pkix.AttributeTypeAndValue). The challenge password value
|
|
// is stored as a string in the inner AttributeTypeAndValue.Value field.
|
|
//
|
|
// Audit M-028 carve-out: Go's stdlib deprecates `csr.Attributes` for the
|
|
// specific use case of parsing the "requestedExtensions" CSR attribute
|
|
// (OID 1.2.840.113549.1.9.14), pointing callers at `csr.Extensions` /
|
|
// `csr.ExtraExtensions`. challengePassword (OID 1.2.840.113549.1.9.7)
|
|
// per RFC 2985 §5.4.1 is a SEPARATE CSR attribute that cannot be
|
|
// retrieved via Extensions. There is no non-deprecated stdlib API for
|
|
// it; callers either accept the deprecation warning or parse the raw
|
|
// `csr.RawAttributes` ASN.1 themselves. We accept the warning; the
|
|
// staticcheck.conf and golangci-lint rules suppress SA1019 for this
|
|
// specific line per the audit closure note.
|
|
//lint:ignore SA1019 RFC 2985 challengePassword has no non-deprecated stdlib API; see comment above.
|
|
for _, attr := range csr.Attributes {
|
|
if attr.Type.Equal(oidChallengePassword) {
|
|
if len(attr.Value) > 0 && len(attr.Value[0]) > 0 {
|
|
if pwd, ok := attr.Value[0][0].Value.(string); ok {
|
|
challengePassword = pwd
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use CN as fallback transaction ID if not found in attributes
|
|
if transactionID == "" && csr.Subject.CommonName != "" {
|
|
transactionID = csr.Subject.CommonName
|
|
}
|
|
|
|
return csrDER, challengePassword, transactionID, nil
|
|
}
|
|
|
|
// pkcs7ContentInfo represents the outer ContentInfo structure.
|
|
type pkcs7ContentInfo struct {
|
|
ContentType asn1.ObjectIdentifier
|
|
Content asn1.RawValue `asn1:"explicit,tag:0"`
|
|
}
|
|
|
|
// pkcs7SignedData represents a simplified SignedData structure for CSR extraction.
|
|
type pkcs7SignedData struct {
|
|
Version int
|
|
DigestAlgorithms asn1.RawValue
|
|
EncapContentInfo asn1.RawValue
|
|
}
|
|
|
|
// pkcs7EncapContent represents the EncapsulatedContentInfo.
|
|
type pkcs7EncapContent struct {
|
|
ContentType asn1.ObjectIdentifier
|
|
Content asn1.RawValue `asn1:"explicit,optional,tag:0"`
|
|
}
|
|
|
|
// parseSignedDataForCSR extracts the encapsulated content (CSR) from PKCS#7 SignedData.
|
|
func parseSignedDataForCSR(data []byte) ([]byte, error) {
|
|
var contentInfo pkcs7ContentInfo
|
|
rest, err := asn1.Unmarshal(data, &contentInfo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse ContentInfo: %w", err)
|
|
}
|
|
if len(rest) > 0 {
|
|
// Trailing data is OK for some implementations
|
|
}
|
|
|
|
// OID for signedData: 1.2.840.113549.1.7.2
|
|
oidSignedData := asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}
|
|
if !contentInfo.ContentType.Equal(oidSignedData) {
|
|
return nil, fmt.Errorf("not SignedData: got OID %v", contentInfo.ContentType)
|
|
}
|
|
|
|
// Parse the SignedData
|
|
var signedData pkcs7SignedData
|
|
_, err = asn1.Unmarshal(contentInfo.Content.Bytes, &signedData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse SignedData: %w", err)
|
|
}
|
|
|
|
// Parse the EncapsulatedContentInfo to get the CSR
|
|
var encapContent pkcs7EncapContent
|
|
_, err = asn1.Unmarshal(signedData.EncapContentInfo.FullBytes, &encapContent)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse EncapsulatedContentInfo: %w", err)
|
|
}
|
|
|
|
if len(encapContent.Content.Bytes) == 0 {
|
|
return nil, fmt.Errorf("empty encapsulated content")
|
|
}
|
|
|
|
// The content may be wrapped in an OCTET STRING
|
|
var csrBytes []byte
|
|
var octetString asn1.RawValue
|
|
if _, err := asn1.Unmarshal(encapContent.Content.Bytes, &octetString); err == nil && octetString.Tag == asn1.TagOctetString {
|
|
csrBytes = octetString.Bytes
|
|
} else {
|
|
csrBytes = encapContent.Content.Bytes
|
|
}
|
|
|
|
// Validate it's a parseable CSR
|
|
if _, err := x509.ParseCertificateRequest(csrBytes); err != nil {
|
|
return nil, fmt.Errorf("extracted content is not a valid CSR: %w", err)
|
|
}
|
|
|
|
return csrBytes, nil
|
|
}
|